PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/class-metasync-seo-sidebar.php +502 -173 2.6.6 → 2.7.2 View file →
@@ -33,8 +33,28 @@
33 33 */
34 34 const META_DESCRIPTION = '_metasync_seo_desc';
35 35
36 36 /**
37 + * Meta keys for values brought in by the external SEO importer.
38 + *
39 + * Deliberately separate from META_SEO_TITLE / META_DESCRIPTION. Those two
40 + * mean "the customer typed this" and therefore outrank OTTO — the Classic
41 + * meta box tells the user as much ("Leave the field blank to use OTTO's
42 + * suggestion"). A bulk import is not a per-post customer decision, so
43 + * writing it there silently opted every imported post out of OTTO.
44 + *
45 + * These keys render only where OTTO has no suggestion. See
46 + * filter_document_title_imported() and output_seo_meta_description().
47 + */
48 + const META_IMPORTED_SEO_TITLE = Metasync_Seo_Precedence::KEY_IMPORTED_TITLE;
49 + const META_IMPORTED_DESCRIPTION = Metasync_Seo_Precedence::KEY_IMPORTED_DESC;
50 +
51 + /**
52 + * Meta key for the OTTO focus keyword (read-only, written by OTTO)
53 + */
54 + const META_OTTO_KEYWORDS = '_metasync_otto_keywords';
55 +
56 + /**
37 57 * Meta key for primary category (used in breadcrumbs and canonical URL)
38 58 */
39 59 const META_PRIMARY_CATEGORY = '_metasync_primary_category';
40 60
@@ -77,13 +97,10 @@
77 97
78 98 // Enqueue block editor assets
79 99 add_action('enqueue_block_editor_assets', array($this, 'enqueue_block_editor_assets'));
80 100
81 - // Track meta state before REST API save, then clean up after
82 - add_filter('rest_pre_insert_post', array($this, 'track_meta_before_save'), 10, 2);
83 - add_filter('rest_pre_insert_page', array($this, 'track_meta_before_save'), 10, 2);
84 - add_action('rest_after_insert_post', array($this, 'cleanup_empty_seo_meta'), 10, 3);
85 - add_action('rest_after_insert_page', array($this, 'cleanup_empty_seo_meta'), 10, 3);
101 + // Clean up emptied SEO meta rows after REST saves (all public post types)
102 + add_action('rest_api_init', array($this, 'register_rest_save_hooks'));
86 103
87 104 // Clean up primary category meta when category is unchecked (all post types)
88 105 add_action('save_post', array($this, 'cleanup_primary_category_meta'), 10, 1);
89 106
@@ -91,11 +108,29 @@
91 108 // Custom values ALWAYS take priority over OTTO suggestions
92 109 add_action('wp_head', array($this, 'output_seo_meta_description'), 2);
93 110 add_filter('pre_get_document_title', array($this, 'filter_document_title'), 100);
94 111 add_filter('document_title_parts', array($this, 'filter_document_title_parts'), 100);
112 +
113 + // Imported values sit BELOW OTTO. Priority 98 runs before OTTO's own
114 + // pre_get_document_title callback (priority 99, see
115 + // otto/metasync-otto-seo-functions.php), so OTTO overwrites this value
116 + // whenever it has a suggestion and leaves it alone when it does not.
117 + // The customer's own title at priority 100 still beats both.
118 + add_filter('pre_get_document_title', array($this, 'filter_document_title_imported'), 98);
95 119 }
96 120
97 121 /**
122 + * Register REST API cleanup hooks for all public post types
123 + */
124 + public function register_rest_save_hooks() {
125 + $post_types = get_post_types(array('public' => true), 'names');
126 + unset($post_types['attachment']);
127 + foreach ($post_types as $post_type) {
128 + add_action("rest_after_insert_{$post_type}", array($this, 'cleanup_empty_seo_meta'), 10, 3);
129 + }
130 + }
131 +
132 + /**
98 133 * Check if OTTO SSR is globally enabled
99 134 *
100 135 * @return bool True if OTTO is enabled globally
101 136 */
@@ -105,66 +140,33 @@
105 140 return ($otto_enabled === 'true' || $otto_enabled === true);
106 141 }
107 142
108 143 /**
109 - * Track meta state before REST API save
110 - * Stores which SEO meta keys existed before the save
111 - *
112 - * @param stdClass $prepared_post Prepared post object
113 - * @param WP_REST_Request $request Request object
114 - * @return stdClass Unmodified prepared post object
115 - */
116 - public function track_meta_before_save($prepared_post, $request) {
117 - $post_id = $request->get_param('id');
118 - if (!$post_id) {
119 - return $prepared_post;
120 - }
121 -
122 - // Track which meta keys existed before this save
123 - $meta_existed = array(
124 - self::META_SEO_TITLE => metadata_exists('post', $post_id, self::META_SEO_TITLE),
125 - self::META_DESCRIPTION => metadata_exists('post', $post_id, self::META_DESCRIPTION),
126 - );
127 -
128 - // Store in transient for use in cleanup
129 - set_transient('metasync_seo_meta_existed_' . $post_id, $meta_existed, 60);
130 -
131 - return $prepared_post;
132 - }
133 -
134 - /**
135 - * Clean up empty SEO meta values after REST API save
136 - * This prevents blanking fields that are showing OTTO fallback values
137 - *
138 - * Deletes meta keys that are empty AND didn't exist before the save
139 - * (meaning they were showing OTTO fallback, not user-edited values)
140 - *
141 - * @param WP_Post $post Inserted or updated post object
144 + * Clean up empty SEO meta values after a REST save
145 + *
146 + * The REST API cannot delete a meta key — clearing a sidebar field
147 + * persists an empty string, and that empty row would keep outranking
148 + * OTTO/imported suggestions forever. Mirror the classic metabox save
149 + * path instead: an empty value loses its row so the post falls back to
150 + * the suggestion tiers again.
151 + *
152 + * @param WP_Post|int $post Inserted or updated post object (or its ID)
142 153 * @param WP_REST_Request $_request Request object (unused)
143 154 * @param bool $_creating True when creating a post, false when updating (unused)
144 155 */
145 156 public function cleanup_empty_seo_meta($post, $_request, $_creating) {
146 - $post_id = $post->ID;
147 -
148 - // Get the before-save state
149 - $meta_existed = get_transient('metasync_seo_meta_existed_' . $post_id);
150 - if ($meta_existed === false) {
151 - // Transient expired or not set, can't make safe decision
157 + $post_id = is_object($post) ? (int) $post->ID : (int) $post;
158 + if (!$post_id) {
152 159 return;
153 160 }
154 161
155 - // Delete transient
156 - delete_transient('metasync_seo_meta_existed_' . $post_id);
157 -
158 - // Check each of our SEO meta keys
159 - foreach ($meta_existed as $meta_key => $existed_before) {
160 - // Get the current value
162 + foreach (array(self::META_SEO_TITLE, self::META_DESCRIPTION) as $meta_key) {
161 163 $meta_value = get_post_meta($post_id, $meta_key, true);
162 164
163 - // Only delete if:
164 - // 1. Value is now empty
165 - // 2. It didn't exist before the save (was showing OTTO fallback)
166 - if (empty($meta_value) && !$existed_before) {
165 + // Strict '' check: get_post_meta() returns '' for a missing row
166 + // too, where delete_post_meta() is a harmless no-op. A real
167 + // (non-empty) value the user typed is never dropped.
168 + if ($meta_value === '') {
167 169 delete_post_meta($post_id, $meta_key);
168 170 }
169 171 }
170 172 }
@@ -171,9 +173,10 @@
171 173
172 174 /**
173 175 * Output meta description tags from sidebar field
174 176 * Custom values ALWAYS take priority over OTTO
175 - * Outputs: meta description, og:description, and twitter:description
177 + * Outputs: meta description, og:description, and twitter:description,
178 + * plus og:title / twitter:title when a suppressed third-party tag needs replacing
176 179 */
177 180 public function output_seo_meta_description() {
178 181 // Skip for admin, feeds, etc.
179 182 if (is_admin() || is_feed() || is_robots()) {
@@ -189,32 +192,134 @@
189 192 if (!$post_id) {
190 193 return;
191 194 }
192 195
193 - // WP-196: When synced to an active SEO plugin, that plugin outputs
194 - // the description from its native storage — skip MetaSync's own tag.
195 196 $conflict_handler = Metasync_SEO_Conflict_Handler::get_instance();
197 +
198 + // og:title / twitter:title replacement (WP-609).
199 + //
200 + // Still needed, but for a narrower case than when it was written. The
201 + // conflict-handler filters now hand a third-party plugin MetaSync's OG
202 + // title whenever one is set, so that plugin renders it and nothing is
203 + // required here. What the filters cannot cover is a post holding a page
204 + // title and no OG-specific value: there they pass the plugin's own tag
205 + // through, and without this block the page carries the plugin's og:title
206 + // while <title> carries ours. og_title_needs_replacement() returns false
207 + // once the filters are supplying the tag, so the two never both emit.
208 + if ($conflict_handler->og_title_needs_replacement()) {
209 + $title = $conflict_handler->get_metasync_title();
210 + if (!empty($title)) {
211 + $title_escaped = esc_attr($title);
212 + echo '<meta property="og:title" content="' . $title_escaped . '" data-metasync-seo="custom" />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
213 + echo '<meta name="twitter:title" content="' . $title_escaped . '" data-metasync-seo="custom" />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
214 + }
215 + }
216 +
217 + // With an active third-party SEO plugin, that plugin emits the description
218 + // tag and the conflict handler hands it MetaSync's value when MetaSync
219 + // holds one. Emitting here as well would put two identical description
220 + // tags on the page.
221 + //
222 + // The condition is "will that plugin render a description", not "has a
223 + // sync run": a timestamp only records that a sync happened, and the
224 + // mirrored copy can be edited or emptied in the plugin afterwards. When
225 + // neither side holds anything we fall through, so a stale or partial sync
226 + // cannot drop the description entirely.
227 + $defer_meta_description = false;
196 228 if ($conflict_handler->has_active_seo_plugin()) {
197 - $sync_ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
198 - if (!empty($sync_ts)) {
199 - return;
229 + if ($conflict_handler->metasync_has_description()
230 + || $conflict_handler->active_plugin_has_description($post_id)) {
231 + $defer_meta_description = true;
200 232 }
201 233 }
202 234
203 - // Get the custom SEO description (takes priority)
204 - $description = get_post_meta($post_id, self::META_DESCRIPTION, true);
235 + // With a plugin active the conflict handler now supplies og:/twitter:
236 + // description too — falling back to the page description when no
237 + // OG-specific value is set — so that plugin renders exactly one. Emitting
238 + // here as well is always a second tag, never a replacement.
239 + $defer_og_description = $conflict_handler->has_active_seo_plugin()
240 + || $conflict_handler->third_party_owns_og_description();
205 241
242 + if ($defer_meta_description && $defer_og_description) {
243 + return;
244 + }
245 +
246 + // Which stored value wins is decided in one place, so the global
247 + // "SEO Meta Priority" setting reaches this emitter
248 + // without a second copy of the rule. Under the default this is still
249 + // the customer's description; under OTTO priority the chain hands back
250 + // OTTO's value and falls back to the custom one where OTTO has none.
251 + $resolved = Metasync_Seo_Precedence::resolve($post_id, Metasync_Seo_Precedence::FIELD_DESCRIPTION);
252 + $description = $resolved['value'];
253 +
254 + // The marker has to describe what actually won, not which code path
255 + // emitted it: Otto_html_class treats data-metasync-seo="custom" as an
256 + // explicit override that must survive the buffer pass, and a tag
257 + // carrying OTTO's own value must not claim to be one. The resolver
258 + // already names the tier it took the value from, so the marker follows
259 + // that rather than being inferred a second time here.
260 + $is_imported_fallback = $resolved['source'] === Metasync_Seo_Precedence::SOURCE_IMPORTED;
261 + $is_otto_value = Metasync_Seo_Precedence::is_otto_value(
262 + $post_id,
263 + Metasync_Seo_Precedence::FIELD_DESCRIPTION,
264 + $resolved
265 + );
266 +
267 + // Exactly one printer owns each description value. When OTTO's stored
268 + // suggestion is the resolved winner, this emitter stands down and
269 + // metasync_output_otto_meta_description() (wp_head priority 1) prints
270 + // it — that emitter already covers every delivery path this one runs
271 + // on, and both firing ships two identical description tags on pages
272 + // the OTTO SSR buffer dedup never reaches (cold/served-from-cache,
273 + // rate-limited, or Cloudflare-pixel mode). Every other tier — the
274 + // customer's value, the persisted pair, imported fallbacks — is still
275 + // printed here, and the og:title replacement above is unaffected.
276 + // Mirrors the ownership rule in that emitter's stand-down guard.
277 + if ($resolved['key'] === Metasync_Seo_Precedence::KEY_OTTO_DESC) {
278 + return;
279 + }
280 +
281 + // An imported value is migration data rather than a per-post customer
282 + // decision, so it only renders where MetaSync owns the output.
283 + if ($is_imported_fallback && $this->third_party_owns_output()) {
284 + $description = '';
285 + $is_imported_fallback = false;
286 + }
287 +
206 288 // Output meta description tags if custom value exists
207 289 // This will be output BEFORE OTTO processes the page, and since we're using
208 290 // a high priority filter, it will override OTTO's meta description
209 291 if (!empty($description)) {
210 292 $description_escaped = esc_attr($description);
293 + if ($is_imported_fallback) {
294 + $marker = ' data-metasync-seo="imported"';
295 + } elseif ($is_otto_value) {
296 + $marker = ' data-metasync-otto="true"';
297 + } else {
298 + $marker = ' data-metasync-seo="custom"';
299 + }
211 300 // Standard meta description
212 - echo '<meta name="description" content="' . $description_escaped . '" data-metasync-seo="custom" />' . "\n";
213 - // Open Graph description
214 - echo '<meta property="og:description" content="' . $description_escaped . '" data-metasync-seo="custom" />' . "\n";
215 - // Twitter description
216 - echo '<meta name="twitter:description" content="' . $description_escaped . '" data-metasync-seo="custom" />' . "\n";
301 + if (!$defer_meta_description) {
302 + echo '<meta name="description" content="' . $description_escaped . '"' . $marker . ' />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
303 + }
304 +
305 + // og:description / twitter:description are Open Graph & social tags, so they
306 + // answer to the site-wide Social Media & Open Graph switch as well as the
307 + // per-post "Enable Open Graph & Social Media Tags" toggle. For the per-post
308 + // toggle only an explicit '0' opt-out disables them (unset/empty counts as
309 + // enabled), mirroring Metasync_OpenGraph::will_emit(). When either says no,
310 + // MetaSync emits no OG/Twitter description, so it neither overrides a
311 + // third-party plugin's tag nor leaves one sourced from our database.
312 + //
313 + // The plain <meta name="description"> above is deliberately NOT gated here:
314 + // it belongs to the SEO title/description feature, which has its own setting.
315 + if (!$defer_og_description
316 + && !Metasync_OpenGraph::is_social_output_disabled($post_id)) {
317 + // Open Graph description
318 + echo '<meta property="og:description" content="' . $description_escaped . '"' . $marker . ' />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
319 + // Twitter description
320 + echo '<meta name="twitter:description" content="' . $description_escaped . '"' . $marker . ' />' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- value already escaped via esc_attr() above; the sniff cannot track custom escaped variables
321 + }
217 322 }
218 323 }
219 324
220 325 /**
@@ -239,19 +344,87 @@
239 344 if (!$post_id) {
240 345 return $title;
241 346 }
242 347
243 - // Get the custom SEO title (takes priority)
244 - $seo_title = get_post_meta($post_id, self::META_SEO_TITLE, true);
348 + // Which stored value wins is decided in one place, so the global
349 + // "SEO Meta Priority" setting reaches this filter
350 + // without a second copy of the rule. Under the default the customer's
351 + // title still wins; under OTTO priority the chain hands back OTTO's
352 + // value and falls back to the custom one where OTTO has none.
353 + //
354 + // The resolver reads the whole chain, so it also answers for a post
355 + // whose title lives on the persisted or imported tier — cases this
356 + // filter used to miss entirely by reading only the sidebar key.
357 + $seo_title = Metasync_Seo_Precedence::value($post_id, Metasync_Seo_Precedence::FIELD_TITLE);
245 358
246 - // Return custom SEO title if set
247 359 return !empty($seo_title) ? $seo_title : $title;
248 360 }
249 361
250 362 /**
363 + * Whether a third-party SEO plugin is the one actually rendering this page.
364 + *
365 + * Mirrors the guard OTTO's own filters use
366 + * (otto/metasync-otto-seo-functions.php): when an active plugin owns output
367 + * and OTTO has nothing live to say, leave that plugin's tags alone.
368 + *
369 + * Gates the *imported* value only. Imported values are migration data with
370 + * no per-post customer intent behind them, so they must never compete with a
371 + * plugin the customer is still using. The description emitter is an echo on
372 + * wp_head rather than a filter return, so without this it would add a second
373 + * <meta name="description"> next to the third-party plugin's own.
374 + *
375 + * @return bool True when MetaSync should stand down for imported values.
376 + */
377 + private function third_party_owns_output() {
378 + if (!class_exists('Metasync_SEO_Conflict_Handler')) {
379 + return false;
380 + }
381 +
382 + $handler = Metasync_SEO_Conflict_Handler::get_instance();
383 +
384 + return $handler->has_active_seo_plugin() && !$handler->otto_has_live_suggestions();
385 + }
386 +
387 + /**
388 + * Supply an imported SEO title as a fallback BELOW OTTO.
389 + *
390 + * Registered at priority 98 so OTTO's own callback (priority 99) runs
391 + * afterwards: OTTO overwrites this whenever it has a suggestion, and
392 + * returns the title untouched when it does not — which is exactly when the
393 + * imported value should show. The customer's own title (priority 100) still
394 + * wins over both.
395 + *
396 + * @param string $title Current title.
397 + * @return string Imported title when it should apply, else unchanged.
398 + */
399 + public function filter_document_title_imported($title) {
400 + if (is_admin() || !is_singular()) {
401 + return $title;
402 + }
403 +
404 + $post_id = get_the_ID();
405 + if (!$post_id) {
406 + return $title;
407 + }
408 +
409 + // Never outrank a third-party SEO plugin that is the one actually
410 + // rendering this page.
411 + if ($this->third_party_owns_output()) {
412 + return $title;
413 + }
414 +
415 + $imported = get_post_meta($post_id, self::META_IMPORTED_SEO_TITLE, true);
416 +
417 + return (!empty($imported) && is_string($imported)) ? $imported : $title;
418 + }
419 +
420 + /**
251 421 * Filter document title parts (for themes using wp_get_document_title)
252 - * Custom values ALWAYS take priority over OTTO
253 422 *
423 + * Which stored value wins is decided by Metasync_Seo_Precedence, so this
424 + * filter follows the global "SEO Meta Priority" setting
425 + * without restating the order.
426 + *
254 427 * @param array $title_parts Title parts array
255 428 * @return array Modified title parts
256 429 */
257 430 public function filter_document_title_parts($title_parts) {
@@ -269,12 +442,11 @@
269 442 if (!$post_id) {
270 443 return $title_parts;
271 444 }
272 445
273 - // Get the custom SEO title (takes priority)
274 - $seo_title = get_post_meta($post_id, self::META_SEO_TITLE, true);
446 + $seo_title = Metasync_Seo_Precedence::value($post_id, Metasync_Seo_Precedence::FIELD_TITLE);
275 447
276 - // Replace title part if custom SEO title is set
448 + // Replace title part if MetaSync holds a title for this post
277 449 if (!empty($seo_title)) {
278 450 $title_parts['title'] = $seo_title;
279 451 // Remove tagline and site for clean SEO title
280 452 unset($title_parts['tagline']);
@@ -297,10 +469,14 @@
297 469 'show_in_rest' => true,
298 470 'single' => true,
299 471 'type' => 'string',
300 472 'sanitize_callback' => 'sanitize_text_field',
301 - 'auth_callback' => function() {
302 - return current_user_can('edit_posts');
473 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
474 + if (empty($object_id)) {
475 + $pt_obj = get_post_type_object($post_type);
476 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
477 + }
478 + return current_user_can('edit_post', $object_id);
303 479 },
304 480 ));
305 481
306 482 // Register meta description
@@ -308,10 +484,14 @@
308 484 'show_in_rest' => true,
309 485 'single' => true,
310 486 'type' => 'string',
311 487 'sanitize_callback' => 'sanitize_textarea_field',
312 - 'auth_callback' => function() {
313 - return current_user_can('edit_posts');
488 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
489 + if (empty($object_id)) {
490 + $pt_obj = get_post_type_object($post_type);
491 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
492 + }
493 + return current_user_can('edit_post', $object_id);
314 494 },
315 495 ));
316 496
317 497 // Register breadcrumb title override
@@ -319,10 +499,14 @@
319 499 'show_in_rest' => true,
320 500 'single' => true,
321 501 'type' => 'string',
322 502 'sanitize_callback' => 'sanitize_text_field',
323 - 'auth_callback' => function() {
324 - return current_user_can('edit_posts');
503 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
504 + if (empty($object_id)) {
505 + $pt_obj = get_post_type_object($post_type);
506 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
507 + }
508 + return current_user_can('edit_post', $object_id);
325 509 },
326 510 ));
327 511
328 512 // Register primary category for breadcrumbs
@@ -330,10 +514,14 @@
330 514 'show_in_rest' => true,
331 515 'single' => true,
332 516 'type' => 'integer',
333 517 'sanitize_callback' => 'absint',
334 - 'auth_callback' => function() {
335 - return current_user_can('edit_posts');
518 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
519 + if (empty($object_id)) {
520 + $pt_obj = get_post_type_object($post_type);
521 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
522 + }
523 + return current_user_can('edit_post', $object_id);
336 524 },
337 525 ));
338 526
339 527 // Register primary product category meta (WooCommerce-specific)
@@ -341,10 +529,14 @@
341 529 'show_in_rest' => true,
342 530 'single' => true,
343 531 'type' => 'integer',
344 532 'sanitize_callback' => 'absint',
345 - 'auth_callback' => function() {
346 - return current_user_can('edit_posts');
533 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
534 + if (empty($object_id)) {
535 + $pt_obj = get_post_type_object($post_type);
536 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
537 + }
538 + return current_user_can('edit_post', $object_id);
347 539 },
348 540 ));
349 541
350 542 // Register OTTO meta keys for REST API (read-only for fallback/prefill)
@@ -351,10 +543,14 @@
351 543 register_post_meta($post_type, '_metasync_otto_title', array(
352 544 'show_in_rest' => true,
353 545 'single' => true,
354 546 'type' => 'string',
355 - 'auth_callback' => function() {
356 - return current_user_can('edit_posts');
547 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
548 + if (empty($object_id)) {
549 + $pt_obj = get_post_type_object($post_type);
550 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
551 + }
552 + return current_user_can('edit_post', $object_id);
357 553 },
358 554 ));
359 555
360 556 register_post_meta($post_type, '_metasync_otto_description', array(
@@ -360,51 +556,104 @@
360 556 register_post_meta($post_type, '_metasync_otto_description', array(
361 557 'show_in_rest' => true,
362 558 'single' => true,
363 559 'type' => 'string',
364 - 'auth_callback' => function() {
365 - return current_user_can('edit_posts');
560 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
561 + if (empty($object_id)) {
562 + $pt_obj = get_post_type_object($post_type);
563 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
564 + }
565 + return current_user_can('edit_post', $object_id);
366 566 },
367 567 ));
368 568
369 - // Register OTTO disabled flag for REST API (to check if OTTO is disabled per-post)
370 - register_post_meta($post_type, '_metasync_otto_disabled', array(
569 + // Register imported SEO meta for REST API (read-only fallback shown
570 + // as a placeholder only when OTTO has no suggestion)
571 + foreach (array(self::META_IMPORTED_SEO_TITLE, self::META_IMPORTED_DESCRIPTION) as $imported_key) {
572 + register_post_meta($post_type, $imported_key, array(
573 + 'show_in_rest' => true,
574 + 'single' => true,
575 + 'type' => 'string',
576 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
577 + if (empty($object_id)) {
578 + $pt_obj = get_post_type_object($post_type);
579 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
580 + }
581 + return current_user_can('edit_post', $object_id);
582 + },
583 + ));
584 + }
585 +
586 + // Register OTTO focus keyword for REST API (read-only display in sidebar)
587 + register_post_meta($post_type, self::META_OTTO_KEYWORDS, array(
371 588 'show_in_rest' => true,
372 589 'single' => true,
373 590 'type' => 'string',
374 - 'auth_callback' => function() {
375 - return current_user_can('edit_posts');
591 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
592 + if (empty($object_id)) {
593 + $pt_obj = get_post_type_object($post_type);
594 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
595 + }
596 + return current_user_can('edit_post', $object_id);
376 597 },
377 598 ));
378 599
379 - // Register hreflang / language alternates meta (JSON-encoded array)
380 - register_post_meta($post_type, self::META_HREFLANG, array(
600 + // Register OTTO disabled flag for REST API (to check if OTTO is disabled per-post)
601 + register_post_meta($post_type, '_metasync_otto_disabled', array(
381 602 'show_in_rest' => true,
382 603 'single' => true,
383 604 'type' => 'string',
384 - 'sanitize_callback' => function($value) {
385 - $decoded = json_decode($value, true);
386 - if (!is_array($decoded)) {
387 - return '[]';
605 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
606 + if (empty($object_id)) {
607 + $pt_obj = get_post_type_object($post_type);
608 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
388 609 }
389 - return wp_json_encode($decoded);
610 + return current_user_can('edit_post', $object_id);
390 611 },
391 - 'auth_callback' => function() {
392 - return current_user_can('edit_posts');
393 - },
394 612 ));
395 613
396 - // Register plugin sync timestamp meta (WP-196)
614 + // Register hreflang / language alternates meta (JSON-encoded array).
615 + // Gated on the Language Alternates feature flag: when the feature is
616 + // disabled the meta is not registered for REST, so the Gutenberg panel
617 + // has nothing to bind to. Saved values are kept regardless.
618 + //
619 + // defined() rather than class_exists(): during the upgrade window a
620 + // stale opcache copy of Metasync_Feature_Flags satisfies
621 + // class_exists() while lacking a newly added constant, and reading
622 + // LANGUAGE_ALTERNATES off it is a fatal. defined() checks the
623 + // constant itself, which is the thing this line actually needs.
624 + // @phpstan-ignore-next-line function.alreadyNarrowedType
625 + if (defined('Metasync_Feature_Flags::LANGUAGE_ALTERNATES') && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::LANGUAGE_ALTERNATES)) {
626 + register_post_meta($post_type, self::META_HREFLANG, array(
627 + 'show_in_rest' => true,
628 + 'single' => true,
629 + 'type' => 'string',
630 + 'sanitize_callback' => array(__CLASS__, 'sanitize_hreflang_meta'),
631 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
632 + if (empty($object_id)) {
633 + $pt_obj = get_post_type_object($post_type);
634 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
635 + }
636 + return current_user_can('edit_post', $object_id);
637 + },
638 + ));
639 + }
640 +
641 + // Register plugin sync timestamp meta
397 642 register_post_meta($post_type, self::META_PLUGIN_SYNC_TS, array(
398 643 'show_in_rest' => true,
399 644 'single' => true,
400 645 'type' => 'string',
401 - 'auth_callback' => function() {
402 - return current_user_can('edit_posts');
646 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
647 + if (empty($object_id)) {
648 + $pt_obj = get_post_type_object($post_type);
649 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
650 + }
651 + return current_user_can('edit_post', $object_id);
403 652 },
404 653 ));
405 654
406 - // Register advanced robots directives meta (WP-197)
655 + // Register advanced robots directives meta
407 656 register_post_meta($post_type, self::META_ROBOTS_ADVANCED, array(
408 657 'show_in_rest' => true,
409 658 'single' => true,
410 659 'type' => 'string',
@@ -432,10 +681,14 @@
432 681 $sanitized['max_video_preview'] = (int) $decoded['max_video_preview'];
433 682 }
434 683 return wp_json_encode($sanitized);
435 684 },
436 - 'auth_callback' => function() {
437 - return current_user_can('edit_posts');
685 + 'auth_callback' => function($allowed, $meta_key, $object_id) use ($post_type) {
686 + if (empty($object_id)) {
687 + $pt_obj = get_post_type_object($post_type);
688 + return $pt_obj ? current_user_can($pt_obj->cap->edit_posts) : current_user_can('edit_posts');
689 + }
690 + return current_user_can('edit_post', $object_id);
438 691 },
439 692 ));
440 693 }
441 694 }
@@ -440,13 +693,67 @@
440 693 }
441 694 }
442 695
443 696 /**
697 + * Sanitize the `_metasync_hreflang` meta value (register_post_meta
698 + * sanitize_callback; static so it is directly testable).
699 + *
700 + * The stored value is a JSON array of {lang, region, url} rows. Rows are
701 + * normalised through Metasync_Hreflang_Output::normalize_entry() (so
702 + * `en_US` becomes `en`+`US`, casing is folded, `x-default` drops its
703 + * region), fully blank rows are dropped, and malformed input degrades to
704 + * an empty array. Rows whose language code still fails validation after
705 + * normalisation are KEPT — the emitter skips them, and the editor panel
706 + * warns — so saving never silently erases what the user typed.
707 + *
708 + * @param mixed $value Incoming JSON string.
709 + * @return string JSON array string.
710 + */
711 + public static function sanitize_hreflang_meta($value) {
712 + // A non-string (e.g. a future importer passing an array straight to
713 + // update_post_meta) must degrade to "no alternates" rather than
714 + // fatal inside sanitize_meta on PHP 8.
715 + if (!is_string($value)) {
716 + return '[]';
717 + }
718 +
719 + $decoded = json_decode($value, true);
720 + if (!is_array($decoded)) {
721 + return '[]';
722 + }
723 +
724 + $clean = array();
725 + foreach ($decoded as $entry) {
726 + if (!is_array($entry)) {
727 + continue;
728 + }
729 + $normalized = Metasync_Hreflang_Output::normalize_entry($entry);
730 + if ($normalized['lang'] === '' && $normalized['url'] === '') {
731 + // A stray blank row ("Add alternate" clicked, never filled
732 + // in) must not flip the post into MetaSync-emits mode on
733 + // WPML sites.
734 + continue;
735 + }
736 + $clean[] = array(
737 + 'lang' => $normalized['lang'],
738 + 'region' => $normalized['region'],
739 + 'url' => $normalized['url'],
740 + );
741 + }
742 +
743 + // UNESCAPED_* keeps non-ASCII URLs readable in storage and free of
744 + // \uXXXX escapes that could be corrupted by a later unslash.
745 + return wp_json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
746 + }
747 +
748 + /**
444 749 * Build a read-only list of WPML translation entries for a post.
445 750 *
446 - * Returns an array of {lang, url} objects by querying the
447 - * `icl_translations` table. Used to seed the "Language Alternates"
448 - * Gutenberg panel with auto-populated values.
751 + * Delegates to the shared implementation in
752 + * Metasync_Hreflang_Output::get_wpml_entries() so the editor panel shows
753 + * exactly what the front end emits — including the synthesised
754 + * x-default row and the region part — instead of a divergent copy of
755 + * the query.
449 756 *
450 757 * @param int $post_id Post ID.
451 758 * @return array
452 759 */
@@ -454,46 +761,20 @@
454 761 if (!defined('ICL_SITEPRESS_VERSION') || $post_id <= 0) {
455 762 return array();
456 763 }
457 764
458 - global $wpdb;
459 - $post = get_post($post_id);
460 - if (!$post) {
461 - return array();
462 - }
765 + $hreflang = new Metasync_Hreflang_Output();
766 + $entries = $hreflang->get_wpml_entries($post_id);
463 767
464 - $element_type = 'post_' . $post->post_type;
465 - $table = $wpdb->prefix . 'icl_translations';
466 -
467 - $trid = $wpdb->get_var($wpdb->prepare(
468 - "SELECT trid FROM {$table} WHERE element_id = %d AND element_type = %s LIMIT 1",
469 - $post_id,
470 - $element_type
471 - ));
472 - if (empty($trid)) {
473 - return array();
474 - }
475 -
476 - $rows = $wpdb->get_results($wpdb->prepare(
477 - "SELECT language_code, element_id FROM {$table} WHERE trid = %d",
478 - $trid
479 - ));
480 - if (empty($rows)) {
481 - return array();
482 - }
483 -
484 - $entries = array();
485 - foreach ($rows as $row) {
486 - $permalink = get_permalink((int) $row->element_id);
487 - if (empty($permalink)) {
488 - continue;
489 - }
490 - $entries[] = array(
491 - 'lang' => (string) $row->language_code,
492 - 'url' => $permalink,
768 + $out = array();
769 + foreach ($entries as $entry) {
770 + $out[] = array(
771 + 'lang' => isset($entry['lang']) ? $entry['lang'] : '',
772 + 'region' => isset($entry['region']) ? $entry['region'] : '',
773 + 'url' => isset($entry['url']) ? $entry['url'] : '',
493 774 );
494 775 }
495 - return $entries;
776 + return $out;
496 777 }
497 778
498 779 /**
499 780 * Enqueue block editor assets
@@ -556,21 +837,31 @@
556 837 if (class_exists('Metasync') && method_exists('Metasync', 'get_whitelabel_otto_name')) {
557 838 $otto_name = Metasync::get_whitelabel_otto_name();
558 839 }
559 840
560 - // Get current post ID and check if meta keys exist in database
561 - $post_id = isset($_GET['post']) ? intval($_GET['post']) : 0;
562 - $has_seo_title = false;
563 - $has_seo_description = false;
841 + // Get current post ID (used for the LPS/custom-page check and WPML
842 + // entries below)
843 + $post_id = isset($_GET['post']) ? intval($_GET['post']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only post ID for editor script data
564 844
565 - if ($post_id > 0) {
566 - // Check if meta keys exist in database (even if value is empty)
567 - $has_seo_title = metadata_exists('post', $post_id, self::META_SEO_TITLE);
568 - $has_seo_description = metadata_exists('post', $post_id, self::META_DESCRIPTION);
569 - }
845 + // LPS / custom-HTML pages bake their own SEO — suppress the editable sidebar
846 + // panels and surface a read-only notice instead.
847 + // otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only
848 + // required conditionally, so the function_exists guard is needed at runtime.
849 + $is_lps_page = function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id);
570 850
571 851 // Auto-detected WPML entries for the "Language Alternates" panel.
572 - $wpml_entries = $this->get_wpml_entries_for_post($post_id);
852 + // Gated on the feature flag: when disabled the panel is hidden, so the
853 + // entries are zeroed to avoid seeding data the UI cannot bind to.
854 + //
855 + // defined() rather than class_exists(): see register_post_meta() above —
856 + // a stale opcache copy of the class passes class_exists() but has no
857 + // LANGUAGE_ALTERNATES constant, and reading it is a fatal.
858 + // @phpstan-ignore-next-line function.alreadyNarrowedType
859 + $language_alternates_enabled = defined('Metasync_Feature_Flags::LANGUAGE_ALTERNATES')
860 + && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::LANGUAGE_ALTERNATES);
861 + $wpml_entries = $language_alternates_enabled
862 + ? $this->get_wpml_entries_for_post($post_id)
863 + : array();
573 864
574 865 // Link Suggestions configuration
575 866 $link_suggestions_config = array(
576 867 'restUrl' => rest_url('metasync/v1/link-suggestions'),
@@ -621,8 +912,9 @@
621 912
622 913 // Localize script with meta keys and settings
623 914 wp_localize_script('metasync-seo-sidebar', 'metasyncSeoSidebar', array(
624 915 'iconUrl' => $icon_url,
916 + 'isCustomOrLpsPage' => $is_lps_page,
625 917 'otherSeoPrimary' => $other_seo_primary,
626 918 'metaKeys' => array(
627 919 'seoTitle' => self::META_SEO_TITLE,
628 920 'metaDescription' => self::META_DESCRIPTION,
@@ -627,12 +919,17 @@
627 919 'seoTitle' => self::META_SEO_TITLE,
628 920 'metaDescription' => self::META_DESCRIPTION,
629 921 // Breadcrumb meta keys
630 922 'breadcrumbTitle' => '_metasync_breadcrumb_title',
631 - 'primaryCategory' => '_metasync_primary_category',
632 923 // OTTO keys for fallback (read-only, used to prefill if manual fields are empty)
633 924 'ottoTitle' => '_metasync_otto_title',
634 925 'ottoDescription' => '_metasync_otto_description',
926 + // Imported keys (read-only). Rank below OTTO — used as the
927 + // placeholder only when OTTO has no suggestion for the post.
928 + 'importedTitle' => self::META_IMPORTED_SEO_TITLE,
929 + 'importedDescription' => self::META_IMPORTED_DESCRIPTION,
930 + // OTTO focus keyword (read-only display only)
931 + 'ottoKeywords' => self::META_OTTO_KEYWORDS,
635 932 // OTTO disabled per-post flag
636 933 'ottoDisabled' => '_metasync_otto_disabled',
637 934 // Primary category
638 935 'primaryCategory' => self::META_PRIMARY_CATEGORY,
@@ -638,11 +935,11 @@
638 935 'primaryCategory' => self::META_PRIMARY_CATEGORY,
639 936 'primaryProductCat' => self::META_PRIMARY_PRODUCT_CAT,
640 937 // Language alternates (hreflang) — JSON-encoded array
641 938 'hreflang' => self::META_HREFLANG,
642 - // Plugin sync timestamp (WP-196)
939 + // Plugin sync timestamp
643 940 'pluginSyncTs' => self::META_PLUGIN_SYNC_TS,
644 - // Advanced robots directives (WP-197)
941 + // Advanced robots directives
645 942 'robotsAdvanced' => self::META_ROBOTS_ADVANCED,
646 943 ),
647 944 'activeSeoPlugins' => array(
648 945 'yoast' => defined('WPSEO_VERSION'),
@@ -648,14 +945,23 @@
648 945 'yoast' => defined('WPSEO_VERSION'),
649 946 'rankmath' => defined('RANK_MATH_VERSION'),
650 947 'aioseo' => defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO'),
651 948 ),
949 + // Breadcrumb Title Override visibility. False only when MetaSync
950 + // breadcrumbs are off AND no Yoast/Rank Math breadcrumb provider is
951 + // active — in that state the override can never reach a trail.
952 + // Stored overrides are never deleted, so this is display-only.
953 + 'breadcrumbs' => array(
954 + 'titleFieldEnabled' => class_exists('Metasync_Breadcrumbs')
955 + ? Metasync_Breadcrumbs::is_breadcrumb_title_meaningful()
956 + : true,
957 + ),
652 958 'wpmlEntries' => $wpml_entries,
653 - 'hasMetaKeys' => array(
654 - // Whether the manual meta keys exist in database (PHP check)
655 - 'seoTitle' => $has_seo_title,
656 - 'metaDescription' => $has_seo_description,
657 - ),
959 + // '1' when enabled, '' when disabled. wp_localize_script() casts
960 + // top-level scalars with (string), so a raw boolean false would
961 + // arrive as "" and could not be distinguished from a missing key
962 + // by a strict JS check. Follows the isCustomOrLpsPage pattern.
963 + 'languageAlternatesEnabled' => $language_alternates_enabled ? '1' : '',
658 964 'otto' => array(
659 965 'globalEnabled' => self::is_otto_enabled_globally(),
660 966 'name' => $otto_name,
661 967 ),
@@ -679,22 +985,31 @@
679 985 'seoTitleLabel' => __('SEO Title', 'metasync'),
680 986 'seoTitleHelp' => __('The title that appears in search engine results. Optimal length: 50-60 characters.', 'metasync'),
681 987 'metaDescriptionLabel' => __('Meta Description', 'metasync'),
682 988 'metaDescriptionHelp' => __('A brief description for search engine results. Optimal length: 120-160 characters.', 'metasync'),
683 - 'urlSlugLabel' => __('URL Slug', 'metasync'),
684 - 'urlSlugHelp' => __('The URL-friendly version of the post name. Use lowercase letters, numbers, and hyphens only.', 'metasync'),
685 989 'serpPreviewTitle' => __('SERP Preview', 'metasync'),
686 990 'serpPreviewHelp' => __('Preview how your page will appear in Google search results.', 'metasync'),
687 991 'serpDesktop' => __('Desktop', 'metasync'),
688 992 'serpMobile' => __('Mobile', 'metasync'),
689 993 'characters' => __('characters', 'metasync'),
690 - 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
691 - 'primaryCategoryHelp' => __('Used in breadcrumbs and canonical URL when multiple categories are assigned.', 'metasync'),
692 994 'primaryCategoryNote' => __('Assign 2+ categories to enable this option.', 'metasync'),
693 995 'ottoPrefillHelp' => sprintf(
996 + /* translators: %s: OTTO name (whitelabel). */
694 997 __('Pre-filled from %s. Edit to customize.', 'metasync'),
695 998 $otto_name
696 999 ),
1000 + 'importedPrefillHelp' => sprintf(
1001 + /* translators: %s: OTTO name (whitelabel) */
1002 + __('Suggestion imported from another SEO plugin. Used only until %s has its own suggestion for this page.', 'metasync'),
1003 + $otto_name
1004 + ),
1005 + // Focus Keyword: read-only field surfacing the OTTO keyword
1006 + 'focusKeywordLabel' => __('Focus Keyword', 'metasync'),
1007 + 'focusKeywordHelp' => sprintf(
1008 + /* translators: %s: OTTO name (whitelabel) */
1009 + __('Managed by %s. Set in Search Atlas — read-only here.', 'metasync'),
1010 + $otto_name
1011 + ),
697 1012 'breadcrumbPanelTitle' => __('Breadcrumbs', 'metasync'),
698 1013 'breadcrumbTitleLabel' => __('Breadcrumb Title Override', 'metasync'),
699 1014 'breadcrumbTitleHelp' => __('Custom label for this page in breadcrumb trails. Leave empty to use the post title.', 'metasync'),
700 1015 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
@@ -700,9 +1015,10 @@
700 1015 'primaryCategoryLabel' => __('Primary Category', 'metasync'),
701 1016 'primaryCategoryHelp' => __('Select which category appears in the breadcrumb path when this post belongs to multiple categories.', 'metasync'),
702 1017 /* translators: %s: OTTO name (whitelabel) */
703 1018 'ottoOverrideNotice' => sprintf(
704 - __('%s is enabled. Any SEO title and description changes from %s will be overwritten by your custom values entered here.', 'metasync'),
1019 + /* translators: 1: OTTO name (whitelabel), 2: OTTO name (whitelabel). */
1020 + __('%1$s is enabled. Any SEO title and description changes from %2$s will be overwritten by your custom values entered here.', 'metasync'),
705 1021 $otto_name,
706 1022 $otto_name
707 1023 ),
708 1024 // Language Alternates (hreflang) panel strings
@@ -712,9 +1028,11 @@
712 1028 'regionLabel' => __('Region', 'metasync'),
713 1029 'urlLabel' => __('URL', 'metasync'),
714 1030 'editManually' => __('Edit Manually', 'metasync'),
715 1031 'wpmlAutoPopulated' => __('Auto-populated from WPML. Click Edit Manually to override.', 'metasync'),
716 - // Advanced Robots Directives (WP-197)
1032 + 'hreflangFormatHelp' => __('Use ISO codes (e.g. en, en-US, x-default — hyphen, not underscore) and absolute URLs (https://…). A self-reference for this page is added automatically when missing.', 'metasync'),
1033 + 'hreflangInvalidRow' => __('This row will not be emitted: the language code must look like "en" or "en-US", and the URL must be absolute (https://…).', 'metasync'),
1034 + // Advanced Robots Directives
717 1035 'robotsAdvancedTitle' => __('Advanced Robots Directives', 'metasync'),
718 1036 'nofollowLabel' => __('Nofollow', 'metasync'),
719 1037 'nofollowHelp' => __('Prevent search engines from following links on this page.', 'metasync'),
720 1038 'noarchiveLabel' => __('Noarchive', 'metasync'),
@@ -726,13 +1044,15 @@
726 1044 'maxSnippetLabel' => __('Max Snippet Length', 'metasync'),
727 1045 'maxSnippetHelp' => __('Maximum character length for text snippets. -1 for unlimited.', 'metasync'),
728 1046 'maxImagePreviewLabel' => __('Max Image Preview', 'metasync'),
729 1047 'maxImagePreviewHelp' => __('Maximum size of image preview in search results.', 'metasync'),
730 - // Plugin Sync Status (WP-196)
1048 + // Plugin Sync Status
731 1049 'syncStatusTitle' => __('Plugin Sync Status', 'metasync'),
732 1050 'syncedTo' => __('Synced to:', 'metasync'),
733 1051 'syncNever' => __('Never synced', 'metasync'),
734 1052 'syncAgo' => __('ago', 'metasync'),
1053 + // LPS / custom-HTML read-only notice
1054 + 'lpsNotice' => __('The SEO for this page is managed by WebStudio.', 'metasync'),
735 1055 ),
736 1056 ));
737 1057 }
738 1058
@@ -742,13 +1062,22 @@
742 1062 *
743 1063 * @param int $post_id Post ID
744 1064 */
745 1065 public function cleanup_primary_category_meta($post_id) {
1066 + if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
1067 + return;
1068 + }
1069 +
746 1070 // Clean up primary category
747 1071 $primary_cat = (int) get_post_meta($post_id, self::META_PRIMARY_CATEGORY, true);
748 1072 if ($primary_cat > 0) {
749 - $post_categories = wp_get_post_categories($post_id);
750 - if (!in_array($primary_cat, $post_categories)) {
1073 + $taxonomies = get_object_taxonomies(get_post_type($post_id), 'names');
1074 + if (empty($taxonomies)) {
1075 + delete_post_meta($post_id, self::META_PRIMARY_CATEGORY);
1076 + return;
1077 + }
1078 + $term_ids = wp_get_object_terms($post_id, $taxonomies, array('fields' => 'ids'));
1079 + if (!is_wp_error($term_ids) && !in_array($primary_cat, $term_ids)) {
751 1080 delete_post_meta($post_id, self::META_PRIMARY_CATEGORY);
752 1081 }
753 1082 }
754 1083