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-classic-seo-fields.php

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

507 lines 18.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Classic-editor save handler for the three Gutenberg-only SEO fields.
4 *
5 * Breadcrumb Title Override, Language Alternates (hreflang), and Primary
6 * Category are persisted client-side in the block editor via the SEO sidebar's
7 * `editPost` -> core REST meta. They have no `save_post` handler, so the
8 * classic post form — which posts a flat form body, not a REST payload — has
9 * no way to write them.
10 *
11 * This class adds that `save_post` path. It is deliberately separate from
12 * Metasync_Seo_Suite: the Suite is presentation-only (it re-renders existing
13 * fields with identical name/nonce attributes and lets the pre-existing
14 * handlers persist them; it contains no save logic at all), and that invariant
15 * is enforced by tests/unit/SeoSuiteParityTest.php. The Suite renders the
16 * breadcrumb-title field and the Languages tab and emits this handler's nonce;
17 * the primary-category select is injected by the accompanying JS under the
18 * core Categories checklist, where the equivalent Gutenberg control lives.
19 *
20 * Guard parity with the Suite:
21 * - bails on autosave / revision (the same guard the other handlers use)
22 * - bails on block-editor requests (the `meta-box-loader=1` re-post and the
23 * `is_block_editor()` screen), mirroring
24 * Metasync_Seo_Suite::is_block_editor_request(). Without this a classic
25 * save would clobber values the Gutenberg sidebar owns.
26 * - bails on LPS / custom-HTML managed pages (no nonces, no writes), matching
27 * the Suite's managed-page lockout so this handler can never overwrite
28 * WebStudio-managed SEO.
29 * - verifies a single nonce, emitted wherever the fields render — by the
30 * Suite for the breadcrumb field and the Languages tab, and beside the
31 * primary-category select for that field alone (the same per-section
32 * conditional-nonce pattern the Suite already uses). The nonce is absent
33 * on Quick Edit, bulk edit, REST saves, and every other path that never
34 * renders these fields — so the handler is inert there.
35 *
36 * Sanitization matches the REST `register_post_meta` registrations in
37 * class-metasync-seo-sidebar.php:
38 * - `_metasync_breadcrumb_title` -> sanitize_text_field (string)
39 * - `_metasync_primary_category` -> absint (int term ID; 0 deletes)
40 * - `_metasync_hreflang` -> the canonical JSON round-trip,
41 * Metasync_SEO_Sidebar::sanitize_hreflang_meta(), so classic-saved rows go
42 * through the same normalisation (en_US -> en+US, case folding,
43 * x-default region strip) as REST-saved rows.
44 *
45 * Writing via update_post_meta is intentional: the Yoast / Rank Math
46 * breadcrumb cross-sync in Metasync_Breadcrumbs fires on
47 * `added_post_meta` / `updated_post_meta`, so the breadcrumb title mirrors
48 * into the third-party plugins for free. The existing
49 * Metasync_SEO_Sidebar::cleanup_primary_category_meta() handler runs at
50 * `save_post` priority 10; WordPress assigns taxonomy terms inside
51 * wp_insert_post before `save_post` fires, so by the time that cleanup reads
52 * the post's terms this priority-9 write is already consistent with them.
53 *
54 * @package Metasync
55 */
56
57 // If this file is called directly, abort.
58 if (!defined('ABSPATH')) {
59 exit;
60 }
61
62 class Metasync_Classic_Seo_Fields
63 {
64 /** Nonce action and field name — emitted by the Suite only when the fields render. */
65 const NONCE_ACTION = 'metasync_classic_seo_fields_nonce';
66 const NONCE_FIELD = 'metasync_classic_seo_fields_nonce';
67
68 /** Meta keys (mirrors the REST registrations in class-metasync-seo-sidebar.php). */
69 const META_BREADCRUMB_TITLE = '_metasync_breadcrumb_title';
70 const META_PRIMARY_CATEGORY = '_metasync_primary_category';
71 const META_HREFLANG = '_metasync_hreflang';
72
73 /** Form key the Suite's Languages tab posts its repeatable rows under. */
74 const HREFLANG_ROWS_FIELD = 'metasync_hreflang_rows';
75
76 /** Form key the Languages tab posts to say "rendered, even with zero rows". */
77 const HREFLANG_PRESENT_FIELD = 'metasync_hreflang_present';
78
79 public function __construct()
80 {
81 // Priority 9 so this runs before the sidebar's
82 // cleanup_primary_category_meta handler (priority 10), which reads the
83 // value written here and only acts if the term is no longer attached.
84 add_action('save_post', array($this, 'save'), 9, 1);
85 add_action('admin_enqueue_scripts', array($this, 'enqueue_assets'));
86 add_action('admin_footer-post.php', array($this, 'inject_primary_category'));
87 add_action('admin_footer-post-new.php', array($this, 'inject_primary_category'));
88 }
89
90 /**
91 * Enqueue the primary-category JS on classic post-edit screens.
92 *
93 * The script builds the select under the core Categories checklist and
94 * keeps it in sync with ticking, unticking, and the "Add New Category"
95 * AJAX path. Only loads where that control can render (see
96 * primary_category_should_render()): classic editor — the block editor's
97 * sidebar owns the field there — post types with the core `category`
98 * taxonomy, and sites where no other SEO plugin provides its own
99 * primary-category control.
100 *
101 * @param string $hook Current admin page hook.
102 */
103 public function enqueue_assets($hook)
104 {
105 if (!in_array($hook, array('post.php', 'post-new.php'), true)) {
106 return;
107 }
108
109 if (!$this->primary_category_should_render()) {
110 return;
111 }
112
113 $post_id = get_the_ID();
114 $stored = $post_id ? (int) get_post_meta($post_id, self::META_PRIMARY_CATEGORY, true) : 0;
115
116 wp_enqueue_script(
117 'metasync-classic-seo-fields',
118 plugin_dir_url(__FILE__) . 'js/metasync-classic-seo-fields.js',
119 array(),
120 defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0',
121 true
122 );
123 wp_localize_script('metasync-classic-seo-fields', 'metasyncClassicSeoFields', array(
124 'selectName' => self::META_PRIMARY_CATEGORY,
125 'storedPrimary' => $stored,
126 'i18n' => array(
127 'label' => __('Primary Category', 'metasync'),
128 'help' => __('Used for breadcrumbs when the post has multiple categories. Options come from the categories checked above.', 'metasync'),
129 'none' => __('(none)', 'metasync'),
130 ),
131 ));
132 }
133
134 /**
135 * True when another SEO plugin provides its own primary-category control.
136 *
137 * Mirrors the $other_seo_primary detection localized for the Gutenberg
138 * sidebar, so the classic select defers exactly where the sidebar's does.
139 *
140 * @return bool
141 */
142 private function other_seo_primary_active()
143 {
144 return defined('WPSEO_VERSION')
145 || defined('RANK_MATH_VERSION')
146 || defined('AIOSEO_VERSION')
147 || class_exists('AIOSEO\\Plugin\\AIOSEO');
148 }
149
150 /**
151 * Echo the primary-category select container for the classic editor.
152 *
153 * Rendered hidden in the admin footer and relocated by the JS under the
154 * core Categories checklist (#categorydiv), where the equivalent
155 * Gutenberg control lives — beside the box the user is already
156 * interacting with, not inside the SEO Suite. The styling is hardcoded
157 * light: this lives inside a core WordPress metabox, so it must not
158 * follow the Suite's Dark/Light toggle.
159 *
160 * Gated identically to the enqueue: block editor, post types without the
161 * core `category` taxonomy, managed pages, and sites where another SEO
162 * plugin owns the primary-category control all get nothing. The save
163 * handler treats the field's absence as "leave the stored value
164 * untouched". The nonce is emitted here too — the Suite prints its own
165 * for its fields, but on a site that opts out of the Suite (or registers
166 * none of its boxes) this field still needs one or it renders as dead UI.
167 */
168 public function inject_primary_category()
169 {
170 if (!$this->primary_category_should_render()) {
171 return;
172 }
173
174 $post_id = get_the_ID();
175 $stored = $post_id ? (int) get_post_meta($post_id, self::META_PRIMARY_CATEGORY, true) : 0;
176 ?>
177 <div id="metasync-primary-category-wrap" data-stored="<?php echo esc_attr((string) $stored); ?>" style="display:none">
178 <?php wp_nonce_field(self::NONCE_ACTION, self::NONCE_FIELD); ?>
179 <label for="metasync-primary-category-select" style="display:block;font-weight:600;margin:0 0 4px"><?php esc_html_e('Primary Category', 'metasync'); ?></label>
180 <select id="metasync-primary-category-select" name="<?php echo esc_attr(self::META_PRIMARY_CATEGORY); ?>" style="width:100%;max-width:300px;padding:4px 6px">
181 <option value="0"><?php esc_html_e('(none)', 'metasync'); ?></option>
182 </select>
183 <p style="margin:4px 0 0;color:#666;font-size:12px"><?php esc_html_e('Used for breadcrumbs when the post has multiple categories. Options come from the categories checked above.', 'metasync'); ?></p>
184 </div>
185 <?php
186 }
187
188 /**
189 * True when the primary-category select can render on this screen.
190 *
191 * @return bool
192 */
193 private function primary_category_should_render()
194 {
195 $screen = function_exists('get_current_screen') ? get_current_screen() : null;
196 if ($screen && $screen->is_block_editor()) {
197 return false;
198 }
199
200 // get_current_post_type() is wp-admin-only and unloaded in the
201 // contexts save_post can reach; the global post is set on both
202 // post.php and post-new.php, so resolve from it like the sidebar's
203 // enqueue does.
204 $post_id = get_the_ID();
205 $post_type = $post_id ? get_post_type($post_id) : '';
206 if (!$post_type) {
207 return false;
208 }
209 $taxonomies = get_object_taxonomies($post_type, 'names');
210 if (!in_array('category', $taxonomies, true)) {
211 return false;
212 }
213
214 // Managed pages: the Suite locks its whole box read-only for these;
215 // this field must not look editable there either.
216 if ($this->is_managed_page($post_id)) {
217 return false;
218 }
219
220 return !$this->other_seo_primary_active();
221 }
222
223 /**
224 * Persist the three classic-editor SEO fields.
225 *
226 * @param int $post_id Post ID being saved.
227 */
228 public function save($post_id)
229 {
230 // Autosave and revision saves must not touch these fields.
231 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
232 return;
233 }
234
235 // The nonce is emitted wherever the fields render — by the Suite for
236 // the breadcrumb field and the Languages tab, and beside the primary
237 // category select for that field alone. It is absent on Quick Edit,
238 // bulk edit, REST, autosave and every other path — so its absence
239 // means "do nothing", exactly like the other classic save handlers.
240 if (!isset($_POST[self::NONCE_FIELD]) || !wp_verify_nonce($_POST[self::NONCE_FIELD], self::NONCE_ACTION)) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the nonce IS the verification
241 return;
242 }
243
244 // The block editor owns these fields there; bail on both the main
245 // block-editor load and the `meta-box-loader=1` re-post.
246 if ($this->is_block_editor_request()) {
247 return;
248 }
249
250 // LPS / custom-HTML managed pages: no nonces, no writes.
251 if ($this->is_managed_page($post_id)) {
252 return;
253 }
254
255 if (!current_user_can('edit_post', $post_id)) {
256 return;
257 }
258
259 $this->save_breadcrumb_title($post_id);
260 $this->save_primary_category($post_id);
261 $this->save_hreflang($post_id);
262 }
263
264 /**
265 * True for any request originating from the block editor.
266 *
267 * Mirrors Metasync_Seo_Suite::is_block_editor_request(): the block editor
268 * renders its meta-box area during the main page load (is_block_editor()
269 * is true there) and re-posts that same form to post.php?meta-box-loader=1
270 * when saving. Both are treated as block-editor context so this handler
271 * never engages in either — a classic save must not clobber values the
272 * Gutenberg sidebar owns. get_current_screen() does not exist outside
273 * wp-admin, and save_post also fires on REST saves, hence the guard.
274 *
275 * @return bool
276 */
277 private function is_block_editor_request()
278 {
279 if (!empty($_REQUEST['meta-box-loader'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag for editor context
280 return true;
281 }
282 if (function_exists('get_current_screen')) {
283 $screen = get_current_screen();
284 if ($screen && $screen->is_block_editor()) {
285 return true;
286 }
287 }
288 return false;
289 }
290
291 /**
292 * True when the page's front-end output bypasses WordPress SEO (LPS /
293 * custom-HTML). Reuses the shared helper so this stays in lockstep with
294 * the Suite's managed-page lockout.
295 *
296 * @param int $post_id Post ID.
297 * @return bool
298 */
299 private function is_managed_page($post_id)
300 {
301 if (function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id)) {
302 return true;
303 }
304 // Fallbacks if the helper isn't loaded on this request — mirrors
305 // Metasync_Seo_Suite::is_managed_page().
306 if ($post_id > 0) {
307 if (get_post_meta($post_id, '_metasync_lps_import', true)) {
308 return true;
309 }
310 if (get_post_meta($post_id, '_metasync_is_custom_html_page', true) === '1'
311 && get_post_meta($post_id, '_metasync_raw_html_enabled', true) === '1') {
312 return true;
313 }
314 }
315 return false;
316 }
317
318 /**
319 * True when the Language Alternates editor switch is on.
320 *
321 * The REST registration of the hreflang meta is gated on the same flag, so
322 * the classic write path refuses exactly where the block editor's would.
323 * The defined() guard matches the registration's upgrade-window defence
324 * against a stale opcache copy of the flags class.
325 *
326 * @return bool
327 */
328 private function language_alternates_enabled()
329 {
330 // @phpstan-ignore-next-line function.alreadyNarrowedType
331 return defined('Metasync_Feature_Flags::LANGUAGE_ALTERNATES')
332 && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::LANGUAGE_ALTERNATES);
333 }
334
335 /**
336 * True when the breadcrumb title override is meaningful on this site.
337 *
338 * @return bool
339 */
340 private function breadcrumb_title_meaningful()
341 {
342 return !class_exists('Metasync_Breadcrumbs') || Metasync_Breadcrumbs::is_breadcrumb_title_meaningful();
343 }
344
345 /**
346 * Breadcrumb Title Override. sanitize_text_field matches the REST
347 * registration; an empty string is a deliberate clear. Writing via
348 * update_post_meta fires the Yoast/Rank Math cross-sync for free.
349 *
350 * @param int $post_id Post ID.
351 */
352 private function save_breadcrumb_title($post_id)
353 {
354 if (!isset($_POST[self::META_BREADCRUMB_TITLE])) {
355 return;
356 }
357
358 // Breadcrumbs globally disabled: the field did not render, so leave
359 // the stored value untouched rather than honouring a crafted input.
360 if (!$this->breadcrumb_title_meaningful()) {
361 return;
362 }
363
364 $value = sanitize_text_field(wp_unslash($_POST[self::META_BREADCRUMB_TITLE]));
365 if ($value === '') {
366 delete_post_meta($post_id, self::META_BREADCRUMB_TITLE);
367 return;
368 }
369 // update_post_meta() unslashes again, so re-slash to keep literal
370 // backslashes and quotes in the stored value.
371 update_post_meta($post_id, self::META_BREADCRUMB_TITLE, wp_slash($value));
372 }
373
374 /**
375 * Primary Category. absint matches the REST registration; 0 means "no
376 * primary category" and deletes rather than persisting a zero. The select
377 * is absent from the form on post types without categories (and wherever
378 * another SEO plugin owns the control), in which case the stored value is
379 * left untouched.
380 *
381 * @param int $post_id Post ID.
382 */
383 private function save_primary_category($post_id)
384 {
385 if (!isset($_POST[self::META_PRIMARY_CATEGORY])) {
386 return;
387 }
388
389 $value = absint(wp_unslash($_POST[self::META_PRIMARY_CATEGORY]));
390 if ($value > 0) {
391 update_post_meta($post_id, self::META_PRIMARY_CATEGORY, $value);
392 } else {
393 delete_post_meta($post_id, self::META_PRIMARY_CATEGORY);
394 }
395 }
396
397 /**
398 * Language Alternates (hreflang).
399 *
400 * The classic form posts individual
401 * `metasync_hreflang_rows[N][lang|region|url]` inputs (it cannot post a
402 * JSON string), so assemble them here and run the result through the
403 * canonical REST sanitize callback for storage-format parity.
404 *
405 * The Languages tab also posts a `metasync_hreflang_present` marker. With
406 * every row removed the row inputs are gone from the form entirely, so
407 * without that marker the handler could not tell "user cleared all rows"
408 * (clear the stored value) from "the tab never rendered" (leave the
409 * stored value — the Gutenberg sidebar or a site with the feature
410 * disabled owns it).
411 *
412 * @param int $post_id Post ID.
413 */
414 private function save_hreflang($post_id)
415 {
416 $rows_in = null;
417 if (isset($_POST[self::HREFLANG_ROWS_FIELD])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above
418 $rows_in = wp_unslash($_POST[self::HREFLANG_ROWS_FIELD]);
419 if (!is_array($rows_in)) {
420 $rows_in = array();
421 }
422 } elseif (isset($_POST[self::HREFLANG_PRESENT_FIELD])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above
423 // Tab rendered, all rows removed: a deliberate clear.
424 $rows_in = array();
425 } else {
426 // The tab did not render on this save; the stored value belongs
427 // to whoever did render it.
428 return;
429 }
430
431 // Flag off: the tab did not render, so a crafted submission must not
432 // write what the REST path would refuse.
433 if (!$this->language_alternates_enabled()) {
434 return;
435 }
436
437 $rows = array();
438 foreach ($rows_in as $row) {
439 if (!is_array($row)) {
440 continue;
441 }
442 $rows[] = array(
443 'lang' => isset($row['lang']) && is_string($row['lang']) ? sanitize_text_field($row['lang']) : '',
444 'region' => isset($row['region']) && is_string($row['region']) ? sanitize_text_field($row['region']) : '',
445 'url' => isset($row['url']) && is_string($row['url']) ? sanitize_text_field($row['url']) : '',
446 );
447 }
448
449 $encoded = wp_json_encode($rows);
450 if (!is_string($encoded)) {
451 // Encoding failure: never persist a corrupt value; clear instead.
452 delete_post_meta($post_id, self::META_HREFLANG);
453 return;
454 }
455
456 $clean = $this->sanitize_hreflang_json($encoded);
457 if ($clean === '' || $clean === '[]') {
458 delete_post_meta($post_id, self::META_HREFLANG);
459 return;
460 }
461 // update_post_meta() unslashes again; re-slash for byte-exact storage.
462 update_post_meta($post_id, self::META_HREFLANG, wp_slash($clean));
463 }
464
465 /**
466 * Canonical hreflang JSON sanitisation.
467 *
468 * Delegates to the REST registration's sanitize_callback so classic-saved
469 * rows are normalised identically to REST-saved rows (en_US folded to
470 * en+US, casing folded, x-default region stripped, blank rows dropped).
471 *
472 * @param string $json JSON array of raw rows.
473 * @return string Normalised JSON array string.
474 */
475 private function sanitize_hreflang_json($json)
476 {
477 if (class_exists('Metasync_SEO_Sidebar')) {
478 return (string) Metasync_SEO_Sidebar::sanitize_hreflang_meta($json);
479 }
480
481 // Autoload miss (non-standard install): degrade to the same blank-row
482 // dropping the canonical callback performs, without the full
483 // normalisation.
484 $decoded = json_decode($json, true);
485 if (!is_array($decoded)) {
486 return '[]';
487 }
488 $clean = array();
489 foreach ($decoded as $entry) {
490 if (!is_array($entry)) {
491 continue;
492 }
493 $lang = isset($entry['lang']) ? (string) $entry['lang'] : '';
494 $url = isset($entry['url']) ? (string) $entry['url'] : '';
495 if ($lang === '' && $url === '') {
496 continue;
497 }
498 $clean[] = array(
499 'lang' => $lang,
500 'region' => isset($entry['region']) ? (string) $entry['region'] : '',
501 'url' => $url,
502 );
503 }
504 return (string) wp_json_encode($clean);
505 }
506 }
507