PluginProbe
Gutenberg / 23.6.2
Gutenberg v23.6.2
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / experimental / content-types / class-wp-rest-user-post-types-controller-gutenberg.php

class-wp-rest-user-post-types-controller-gutenberg.php in Gutenberg 23.6.2, at lib/experimental/content-types/class-wp-rest-user-post-types-controller-gutenberg.php

732 lines 25.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API: WP_REST_User_Post_Types_Controller_Gutenberg class
4 *
5 * @package gutenberg
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 if ( class_exists( 'WP_REST_User_Post_Types_Controller_Gutenberg' ) ) {
13 return;
14 }
15
16 /**
17 * REST controller for user-defined post types.
18 *
19 * Extends `WP_REST_Posts_Controller` because user post types are stored
20 * as posts of the private `wp_user_post_type` CPT, not as registered
21 * post types — each record is the registration intent for a post type,
22 * registered on `init` by `gutenberg_register_user_defined_post_types()`.
23 */
24 class WP_REST_User_Post_Types_Controller_Gutenberg extends WP_REST_Posts_Controller {
25
26 /**
27 * Allowed keys inside the stored `config.labels` object. Anything outside
28 * this list is dropped at sanitization time via the schema's
29 * `additionalProperties: false`. The client mirrors this in
30 * `STRING_LABEL_KEYS` (packages/user-post-types/src/utils.ts) — drift is
31 * caught loudly: client-only keys get a 400 from the schema, server-only
32 * additions just don't render in the form.
33 *
34 * @return string[]
35 */
36 private static function get_allowed_label_keys() {
37 return array(
38 'singular_name',
39 'menu_name',
40 'all_items',
41 'add_new',
42 'add_new_item',
43 'edit_item',
44 'new_item',
45 'view_item',
46 'view_items',
47 'search_items',
48 'not_found',
49 'not_found_in_trash',
50 'parent_item_colon',
51 'archives',
52 'attributes',
53 'insert_into_item',
54 'uploaded_to_this_item',
55 'featured_image',
56 'set_featured_image',
57 'remove_featured_image',
58 'use_featured_image',
59 'filter_items_list',
60 'items_list_navigation',
61 'items_list',
62 );
63 }
64
65 /**
66 * Allowed values inside the stored `config.supports` array. Mirrored on
67 * the client by `SUPPORT_FEATURES` (packages/user-post-types/src/utils.ts).
68 * Enforced via `items.enum` in the schema so unknown features get a 400
69 * at write time, the same way unknown label keys do.
70 *
71 * @return string[]
72 */
73 private static function get_allowed_supports() {
74 return array(
75 'title',
76 'editor',
77 'thumbnail',
78 'excerpt',
79 'comments',
80 'revisions',
81 'author',
82 'page-attributes',
83 'custom-fields',
84 'trackbacks',
85 'post-formats',
86 );
87 }
88
89 /**
90 * Returns the typed schema for the `config` blob stored in `post_content`.
91 * Single source of truth on the server: `get_item_schema()` embeds it as
92 * the REST `config` property, and `gutenberg_user_post_type_sanitize_config`
93 * feeds it to `rest_sanitize_value_from_schema()` at write time.
94 *
95 * Mirrors the fields the materializer (`gutenberg_build_user_post_type_args`)
96 * actually consumes — adding a field here without consuming it stores dead
97 * data, and consuming a field that isn't here lets unsanitized values reach
98 * `register_post_type()`.
99 *
100 * @return array
101 */
102 public static function get_config_schema() {
103 $label_props = array();
104 foreach ( self::get_allowed_label_keys() as $key ) {
105 // Headroom over the longest translated core label.
106 $label_props[ $key ] = array(
107 'type' => 'string',
108 'maxLength' => 200,
109 );
110 }
111 return array(
112 'type' => 'object',
113 'additionalProperties' => false,
114 'properties' => array(
115 'public' => array( 'type' => 'boolean' ),
116 'hierarchical' => array( 'type' => 'boolean' ),
117 'has_archive' => array( 'type' => 'boolean' ),
118 'show_in_rest' => array( 'type' => 'boolean' ),
119 // Caps payload size; well above any reasonable description.
120 'description' => array(
121 'type' => 'string',
122 'maxLength' => 1000,
123 ),
124 'supports' => array(
125 'type' => 'array',
126 'items' => array(
127 'type' => 'string',
128 'enum' => self::get_allowed_supports(),
129 ),
130 'uniqueItems' => true,
131 'maxItems' => 50,
132 ),
133 // Storage split: the JSON blob in `post_content` holds only
134 // non-user-defined slugs (core/theme/plugin). Slugs that
135 // match a `wp_user_taxonomy` record live on the taxonomy
136 // side via `_wp_user_taxonomy_object_type` meta. Requests
137 // and responses carry the union; `prepare_item_for_database`
138 // and `prepare_item_for_response` reconcile the two sides.
139 'taxonomies' => array(
140 'type' => 'array',
141 'items' => array(
142 'type' => 'string',
143 // Matches the wp_taxonomies key length cap.
144 'pattern' => '^[a-z0-9_-]{1,32}$',
145 ),
146 'uniqueItems' => true,
147 'maxItems' => 50,
148 ),
149 'labels' => array(
150 'type' => 'object',
151 'additionalProperties' => false,
152 'properties' => $label_props,
153 ),
154 ),
155 );
156 }
157
158 /**
159 * Returns the JSON schema for a single record. Removes the raw `content`
160 * field from the standard posts schema and replaces it with the typed
161 * `config` object.
162 */
163 public function get_item_schema() {
164 if ( $this->schema ) {
165 return $this->add_additional_fields_schema( $this->schema );
166 }
167
168 $schema = parent::get_item_schema();
169 unset( $schema['properties']['content'] );
170
171 $schema['properties']['config'] = array_merge(
172 self::get_config_schema(),
173 array(
174 'description' => __( 'Typed post type configuration.', 'gutenberg' ),
175 'context' => array( 'view', 'edit' ),
176 'default' => array(),
177 )
178 );
179
180 $schema['properties']['count'] = array(
181 'description' => __( 'Number of posts of this post type, matching the count shown in the admin list table\'s "All" view.', 'gutenberg' ),
182 'type' => 'integer',
183 'context' => array( 'view', 'edit' ),
184 'readonly' => true,
185 );
186
187 $this->schema = $schema;
188 return $this->add_additional_fields_schema( $this->schema );
189 }
190
191 /**
192 * Adds the typed `config` object to the response.
193 *
194 * @param WP_Post $item Stored record.
195 * @param WP_REST_Request $request REST request.
196 * @return WP_REST_Response
197 */
198 public function prepare_item_for_response( $item, $request ) {
199 $response = parent::prepare_item_for_response( $item, $request );
200 $data = $response->get_data();
201
202 $fields = $this->get_fields_for_response( $request );
203
204 if ( rest_is_field_included( 'config', $fields ) ) {
205 $decoded = json_decode( (string) $item->post_content, true );
206 $config = ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) )
207 ? $decoded
208 : array();
209 // Storage marker is server-only; never expose it to clients.
210 unset( $config[ GUTENBERG_USER_POST_TYPE_CONFIG_MARKER ] );
211
212 // `config.taxonomies` in the response is the merged view: the
213 // non-user slugs persisted in this record's JSON, plus any
214 // user-defined taxonomies whose `_wp_user_taxonomy_object_type`
215 // meta points back at this post type. Single field for the
216 // client to read and write — the controller splits writes back
217 // into the two storage sites.
218 $stored = isset( $config['taxonomies'] ) && is_array( $config['taxonomies'] )
219 ? array_values( array_filter( $config['taxonomies'], 'is_string' ) )
220 : array();
221 $linked = array();
222 foreach ( self::get_user_taxonomy_ids_with_meta( $item->post_name ) as $tax_id ) {
223 $tax_slug = (string) get_post_field( 'post_name', $tax_id );
224 if ( '' !== $tax_slug ) {
225 $linked[] = $tax_slug;
226 }
227 }
228 $merged = array_values( array_unique( array_merge( $stored, $linked ) ) );
229 if ( ! empty( $merged ) ) {
230 $config['taxonomies'] = $merged;
231 } else {
232 unset( $config['taxonomies'] );
233 }
234
235 // Empty config must serialize as `{}` to match the schema's
236 // `type: 'object'`. PHP encodes empty associative arrays as `[]`
237 // in JSON, so cast empties to stdClass.
238 $data['config'] = empty( $config ) ? new stdClass() : $config;
239 }
240
241 if ( rest_is_field_included( 'count', $fields ) && 'publish' === $item->post_status ) {
242 $num_posts = wp_count_posts( $item->post_name, 'readable' );
243 $total = array_sum( (array) $num_posts );
244 foreach ( get_post_stati( array( 'show_in_admin_all_list' => false ) ) as $state ) {
245 $total -= isset( $num_posts->$state ) ? (int) $num_posts->$state : 0;
246 }
247 $data['count'] = $total;
248 }
249
250 $response->set_data( $data );
251 return $response;
252 }
253
254 /**
255 * Translates the typed `config` field on the request into the JSON blob
256 * that lives in `post_content`, and rejects writes whose slug collides or
257 * has the wrong shape. Structural sanitization is layered onto
258 * `wp_insert_post_data`; this method only encodes.
259 *
260 * @param WP_REST_Request $request REST request.
261 * @return stdClass|WP_Error
262 */
263 protected function prepare_item_for_database( $request ) {
264 $prepared = parent::prepare_item_for_database( $request );
265 if ( is_wp_error( $prepared ) ) {
266 return $prepared;
267 }
268
269 $slug_check = $this->validate_slug( $prepared );
270 if ( is_wp_error( $slug_check ) ) {
271 return $slug_check;
272 }
273
274 // `config` is replaced atomically when the param is present (or on
275 // create). Omitting it from a PUT/PATCH preserves the stored value;
276 // there is no in-`config` partial-update support.
277 if ( $request->has_param( 'config' ) || empty( $request['id'] ) ) {
278 $config = is_array( $request['config'] ) ? $request['config'] : array();
279
280 // User-defined taxonomy slugs ride on the taxonomy record's
281 // `_wp_user_taxonomy_object_type` meta as the single source of
282 // truth, so they're stripped from `config.taxonomies` here and
283 // reapplied via `sync_taxonomy_associations()` after save. The
284 // JSON keeps only core/theme/plugin slugs.
285 if ( isset( $config['taxonomies'] ) && is_array( $config['taxonomies'] ) ) {
286 $ids_by_slug = self::get_user_taxonomy_ids_by_slug();
287 $stored = array();
288 foreach ( $config['taxonomies'] as $slug ) {
289 if ( is_string( $slug ) && ! isset( $ids_by_slug[ $slug ] ) ) {
290 $stored[] = $slug;
291 }
292 }
293 $config['taxonomies'] = array_values( array_unique( $stored ) );
294 }
295
296 // `JSON_HEX_TAG | JSON_HEX_AMP` escape `<`, `>`, and `&` to their
297 // `\u00XX` forms before `wp_insert_post()` is called, so when
298 // kses runs first via `content_save_pre` it sees an inert string
299 // and is a no-op. `JSON_UNESCAPED_SLASHES` keeps URL-like values
300 // readable and round-trips cleanly through `wp_slash`/`wp_unslash`.
301 // Empty object-shaped positions are cast to `stdClass` via
302 // `normalize_config_for_encode()` so they serialize as `{}`,
303 // matching the schema's `type: 'object'` declarations.
304 $prepared->post_content = wp_json_encode(
305 self::normalize_config_for_encode( $config ),
306 JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
307 );
308 }
309
310 return $prepared;
311 }
312
313 /**
314 * Creates a record, then mirrors any `config.taxonomies` user-tax slugs
315 * into the corresponding wp_user_taxonomy records' `object_type` meta.
316 *
317 * @param WP_REST_Request $request REST request.
318 * @return WP_REST_Response|WP_Error
319 */
320 public function create_item( $request ) {
321 $response = parent::create_item( $request );
322 if ( is_wp_error( $response ) ) {
323 return $response;
324 }
325 $data = $response->get_data();
326 $post_id = (int) $data['id'];
327 $this->sync_taxonomy_associations( $post_id, $request );
328
329 // A new record published in this request will register on the next
330 // `init`, so the rewrite rules need to follow.
331 if ( 'publish' === get_post_status( $post_id ) ) {
332 gutenberg_user_content_types_schedule_flush_rewrite_rules();
333 }
334
335 $post = get_post( $post_id );
336 if ( $post instanceof WP_Post ) {
337 $refreshed = $this->prepare_item_for_response( $post, $request );
338 if ( ! is_wp_error( $refreshed ) ) {
339 $response->set_data( $refreshed->get_data() );
340 }
341 }
342 return $response;
343 }
344
345 /**
346 * Updates a record, migrates user-taxonomy back-references when the slug
347 * changes, and re-syncs taxonomy associations from `config.taxonomies`.
348 *
349 * @param WP_REST_Request $request REST request.
350 * @return WP_REST_Response|WP_Error
351 */
352 public function update_item( $request ) {
353 $post_id = (int) $request['id'];
354 $existing = get_post( $post_id );
355 $previous_slug = $existing instanceof WP_Post ? (string) $existing->post_name : '';
356 $previous_status = $existing instanceof WP_Post ? (string) $existing->post_status : '';
357 $previous_config = $existing instanceof WP_Post
358 ? self::decode_stored_config( $existing->post_content )
359 : array();
360
361 $response = parent::update_item( $request );
362 if ( is_wp_error( $response ) ) {
363 return $response;
364 }
365
366 $current_slug = (string) get_post_field( 'post_name', $post_id );
367 if ( '' !== $previous_slug && '' !== $current_slug && $previous_slug !== $current_slug ) {
368 self::migrate_user_tax_back_references( $previous_slug, $current_slug );
369 }
370 $this->sync_taxonomy_associations( $post_id, $request );
371
372 $post = get_post( $post_id );
373 if ( $post instanceof WP_Post && self::update_needs_rewrite_flush( $previous_status, $previous_slug, $previous_config, $post ) ) {
374 gutenberg_user_content_types_schedule_flush_rewrite_rules();
375 }
376
377 if ( $post instanceof WP_Post ) {
378 $refreshed = $this->prepare_item_for_response( $post, $request );
379 if ( ! is_wp_error( $refreshed ) ) {
380 $response->set_data( $refreshed->get_data() );
381 }
382 }
383 return $response;
384 }
385
386 /**
387 * Force-deletes a record after stripping its slug from any user-taxonomy
388 * `_wp_user_taxonomy_object_type` meta that referenced it. Trash (force
389 * = false) leaves the meta intact so untrashing restores the linkage.
390 *
391 * @param WP_REST_Request $request REST request.
392 * @return WP_REST_Response|WP_Error
393 */
394 public function delete_item( $request ) {
395 // Capture the slug and pre-delete status before the parent runs —
396 // once force-delete fires the post is gone and `post_name` /
397 // `post_status` are no longer reachable, and trash will have already
398 // transitioned the row's status by the time we read it back.
399 $force = ! empty( $request['force'] );
400 $existing = get_post( (int) $request['id'] );
401 $slug = ( $force && $existing instanceof WP_Post ) ? (string) $existing->post_name : '';
402 $was_registered = $existing instanceof WP_Post && 'publish' === $existing->post_status;
403 $response = parent::delete_item( $request );
404 if ( is_wp_error( $response ) ) {
405 return $response;
406 }
407 if ( $force && '' !== $slug ) {
408 foreach ( self::get_user_taxonomy_ids_with_meta( $slug ) as $tax_id ) {
409 gutenberg_user_taxonomy_detach_object_type( $tax_id, $slug );
410 }
411 }
412 // Either trash (publish → trash) or force-delete on a previously
413 // published record removes it from the next `init`'s registration,
414 // so the rewrite rules need to follow. Drafts were never registered;
415 // nothing to flush.
416 if ( $was_registered ) {
417 gutenberg_user_content_types_schedule_flush_rewrite_rules();
418 }
419 return $response;
420 }
421
422 /**
423 * Prepares a config array for `wp_json_encode` so every empty
424 * object-shaped position serializes as `{}` rather than `[]`, matching
425 * the schema's `type: 'object'` declarations.
426 *
427 * Drives off `get_config_schema()` so new object-typed fields get the
428 * `[] → {}` cast automatically. The `$schema` param is internal —
429 * public callers pass just `$value`; recursion threads the
430 * sub-schema through.
431 *
432 * @param mixed $value Value to normalize.
433 * @param array|null $schema Schema fragment for `$value`. Defaults to the full config schema.
434 * @return mixed
435 */
436 public static function normalize_config_for_encode( $value, $schema = null ) {
437 $schema = $schema ?? self::get_config_schema();
438 if ( 'object' !== $schema['type'] ) {
439 return $value;
440 }
441 if ( ! is_array( $value ) || empty( $value ) ) {
442 return new stdClass();
443 }
444 foreach ( $schema['properties'] as $key => $sub_schema ) {
445 if ( array_key_exists( $key, $value ) ) {
446 $value[ $key ] = self::normalize_config_for_encode( $value[ $key ], $sub_schema );
447 }
448 }
449 return $value;
450 }
451
452 /**
453 * Validates the prepared post's `post_name` slug. Rejects:
454 * - shapes outside `^[a-z0-9_-]{1,20}$`,
455 * - slugs already taken by another `wp_user_post_type` post,
456 * - slugs reserved by an existing registered post type (core/plugin).
457 *
458 * The Add/Edit modals do the same checks client-side for UX, but the
459 * server is the authoritative gate.
460 *
461 * @param stdClass $prepared Prepared post object.
462 * @return true|WP_Error
463 */
464 private function validate_slug( $prepared ) {
465 $slug = isset( $prepared->post_name ) ? (string) $prepared->post_name : '';
466 $editing_id = isset( $prepared->ID ) ? (int) $prepared->ID : 0;
467
468 // PUT/PATCH without a `slug` param leaves `post_name` empty so WP
469 // keeps the row's existing slug. Skip validation in that case.
470 if ( '' === $slug && $editing_id > 0 ) {
471 return true;
472 }
473
474 if ( ! preg_match( GUTENBERG_USER_POST_TYPE_SLUG_PATTERN, $slug ) ) {
475 return new WP_Error(
476 'gutenberg_user_post_type_slug_invalid',
477 __( 'Post type keys must be 1–20 characters and may only contain lowercase letters, numbers, hyphens, and underscores.', 'gutenberg' ),
478 array( 'status' => 400 )
479 );
480 }
481
482 // Unchanged slug on an existing record — allow.
483 if ( $editing_id > 0 ) {
484 $existing = get_post( $editing_id );
485 if ( $existing && $existing->post_name === $slug ) {
486 return true;
487 }
488 }
489
490 $other_posts = get_posts(
491 array(
492 'post_type' => 'wp_user_post_type',
493 'post_status' => 'any',
494 'name' => $slug,
495 'posts_per_page' => 1,
496 'no_found_rows' => true,
497 'suppress_filters' => true,
498 'post__not_in' => $editing_id > 0 ? array( $editing_id ) : array(),
499 )
500 );
501 if ( ! empty( $other_posts ) ) {
502 return new WP_Error(
503 'gutenberg_user_post_type_slug_taken',
504 __( 'Another user-defined post type already uses this key.', 'gutenberg' ),
505 array( 'status' => 400 )
506 );
507 }
508
509 // Our own register_post_type() step runs at init priority 20 and skips
510 // colliding slugs, so a post_type_exists() hit here means a
511 // non-user-post-type registration owns the slug.
512 if ( post_type_exists( $slug ) ) {
513 return new WP_Error(
514 'gutenberg_user_post_type_slug_reserved',
515 sprintf(
516 /* translators: %s: post type slug */
517 __( 'The post type key "%s" is reserved by an existing post type.', 'gutenberg' ),
518 $slug
519 ),
520 array( 'status' => 400 )
521 );
522 }
523
524 return true;
525 }
526
527 /**
528 * Reapplies the user-taxonomy half of a `config.taxonomies` write. Walks
529 * the requested slugs once, finds the user-defined ones, and reconciles
530 * each affected wp_user_taxonomy record's `_wp_user_taxonomy_object_type`
531 * meta to match. Slugs in the request that already exist in meta are
532 * left alone; previously-attached user taxonomies that are no longer in
533 * the request have the post type slug removed from their meta.
534 *
535 * Skipped when the request omits `config.taxonomies`.
536 *
537 * @param int $post_id Saved post ID.
538 * @param WP_REST_Request $request REST request.
539 */
540 private function sync_taxonomy_associations( $post_id, $request ) {
541 if ( ! $request->has_param( 'config' ) ) {
542 return;
543 }
544 $config = $request['config'];
545 if ( ! is_array( $config ) || ! isset( $config['taxonomies'] ) || ! is_array( $config['taxonomies'] ) ) {
546 return;
547 }
548
549 $post = get_post( $post_id );
550 if ( ! $post instanceof WP_Post ) {
551 return;
552 }
553 $current_slug = (string) $post->post_name;
554
555 $ids_by_slug = self::get_user_taxonomy_ids_by_slug();
556 $desired = array();
557 foreach ( $config['taxonomies'] as $slug ) {
558 if ( is_string( $slug ) && isset( $ids_by_slug[ $slug ] ) ) {
559 $desired[ $slug ] = true;
560 }
561 }
562
563 // Walk previously-attached user taxonomies; detach the post-type
564 // slug from any whose taxonomy isn't in the new request, mark the
565 // rest as already-applied so we don't re-attach them below.
566 foreach ( self::get_user_taxonomy_ids_with_meta( $current_slug ) as $tax_id ) {
567 $tax_slug = get_post_field( 'post_name', $tax_id );
568 if ( ! is_string( $tax_slug ) || '' === $tax_slug ) {
569 continue;
570 }
571 if ( isset( $desired[ $tax_slug ] ) ) {
572 unset( $desired[ $tax_slug ] );
573 } else {
574 gutenberg_user_taxonomy_detach_object_type( $tax_id, $current_slug );
575 }
576 }
577
578 // Anything left in $desired is a newly-requested attachment.
579 foreach ( array_keys( $desired ) as $tax_slug ) {
580 gutenberg_user_taxonomy_attach_object_type( $ids_by_slug[ $tax_slug ], $current_slug );
581 }
582 }
583
584 /**
585 * Rewrites every `_wp_user_taxonomy_object_type` meta row pointing at
586 * `$previous_slug` to `$current_slug`. Called from {@see update_item}
587 * whenever the slug changes, independently of `config.taxonomies` —
588 * a PUT that only renames the slug still needs the back-references
589 * to follow, otherwise every linked user taxonomy silently disconnects.
590 *
591 * @param string $previous_slug Slug the record had before this save.
592 * @param string $current_slug Slug after the save.
593 */
594 private static function migrate_user_tax_back_references( $previous_slug, $current_slug ) {
595 foreach ( self::get_user_taxonomy_ids_with_meta( $previous_slug ) as $tax_id ) {
596 gutenberg_user_taxonomy_detach_object_type( $tax_id, $previous_slug );
597 gutenberg_user_taxonomy_attach_object_type( $tax_id, $current_slug );
598 }
599 }
600
601 /**
602 * Decides whether an `update_item` mutation needs the rewrite rules
603 * regenerated. Triggers on:
604 * - status crossing the `publish` boundary in either direction (a
605 * record going from active → inactive or back, which toggles
606 * `register_post_type()` on next `init`),
607 * - while remaining published, a change in slug, `has_archive`,
608 * `public`, or `hierarchical`. `hierarchical` flips the rewrite
609 * tag regex between `(.+?)` (nested) and `([^/]+)` (flat) and
610 * swaps the query-var fallback between `pagename=` and `name=`.
611 *
612 * Other config edits (labels, supports, taxonomies, description,
613 * show_in_rest) don't affect rewrite rules and are skipped so we
614 * don't flush on every label tweak.
615 *
616 * @param string $previous_status Status the record had before update.
617 * @param string $previous_slug Slug before update.
618 * @param array $previous_config Decoded config before update.
619 * @param WP_Post $current_post Record after update.
620 * @return bool
621 */
622 private static function update_needs_rewrite_flush( $previous_status, $previous_slug, $previous_config, WP_Post $current_post ) {
623 $current_status = (string) $current_post->post_status;
624 $was_active = 'publish' === $previous_status;
625 $is_active = 'publish' === $current_status;
626
627 if ( $was_active !== $is_active ) {
628 return true;
629 }
630 if ( ! $is_active ) {
631 // Drafts aren't registered, so config edits between drafts are
632 // irrelevant to rewrite rules.
633 return false;
634 }
635
636 if ( $previous_slug !== (string) $current_post->post_name ) {
637 return true;
638 }
639
640 $current_config = self::decode_stored_config( $current_post->post_content );
641 if ( ! empty( $previous_config['has_archive'] ) !== ! empty( $current_config['has_archive'] ) ) {
642 return true;
643 }
644 if ( ! empty( $previous_config['public'] ) !== ! empty( $current_config['public'] ) ) {
645 return true;
646 }
647 if ( ! empty( $previous_config['hierarchical'] ) !== ! empty( $current_config['hierarchical'] ) ) {
648 return true;
649 }
650
651 return false;
652 }
653
654 /**
655 * Decodes the stored `post_content` JSON for a wp_user_post_type record
656 * down to its sanitized config array. Returns an empty array on invalid
657 * JSON so callers can use `! empty()` semantics for boolean fields.
658 *
659 * @param string $post_content Raw stored JSON.
660 * @return array
661 */
662 private static function decode_stored_config( $post_content ) {
663 $decoded = json_decode( (string) $post_content, true );
664 if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) {
665 return array();
666 }
667 unset( $decoded[ GUTENBERG_USER_POST_TYPE_CONFIG_MARKER ] );
668 return $decoded;
669 }
670
671 /**
672 * Returns a `slug => ID` map of every wp_user_taxonomy record. Used both
673 * as the user-tax membership check (`isset( $map[ $slug ] )`) and as the
674 * slug-to-ID lookup when reattaching `_wp_user_taxonomy_object_type`
675 * meta. Single bounded query; user taxonomies are a small set.
676 *
677 * @return array<string, int>
678 */
679 private static function get_user_taxonomy_ids_by_slug() {
680 $records = get_posts(
681 array(
682 'post_type' => 'wp_user_taxonomy',
683 'post_status' => 'any',
684 'posts_per_page' => -1,
685 'no_found_rows' => true,
686 'suppress_filters' => true,
687 'update_post_meta_cache' => false,
688 'update_post_term_cache' => false,
689 )
690 );
691 $map = array();
692 foreach ( $records as $record ) {
693 $slug = (string) $record->post_name;
694 if ( '' !== $slug ) {
695 $map[ $slug ] = (int) $record->ID;
696 }
697 }
698 return $map;
699 }
700
701 /**
702 * Returns wp_user_taxonomy record IDs whose
703 * `_wp_user_taxonomy_object_type` meta contains the given post type
704 * slug, fetched directly from the DB.
705 *
706 * @param string $post_type_slug Post type slug to match.
707 * @return int[]
708 */
709 private static function get_user_taxonomy_ids_with_meta( $post_type_slug ) {
710 if ( '' === (string) $post_type_slug ) {
711 return array();
712 }
713 return get_posts(
714 array(
715 'post_type' => 'wp_user_taxonomy',
716 'post_status' => 'any',
717 'posts_per_page' => -1,
718 'fields' => 'ids',
719 'no_found_rows' => true,
720 'suppress_filters' => true,
721 'meta_query' => array(
722 array(
723 'key' => GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY,
724 'value' => $post_type_slug,
725 'compare' => '=',
726 ),
727 ),
728 )
729 );
730 }
731 }
732