array( 'type' => 'array', 'default' => array(), 'ui' => 'post_types_flat', ), // 'choices' is the allowed-value list the settings page validates against; the labels // are translated and therefore live in field_choices(), which runs at render time. 'new_post_position' => array( 'type' => 'string', 'default' => 'bottom', 'ui' => 'radio', 'choices' => array( 'top', 'bottom' ), ), ); } /** * Field labels, resolved at render time. * * @param string $key Field key. * @return string */ public function field_label( $key ) { switch ( $key ) { case 'post_types': return __( 'Let me sort these by hand', 'adminkeep' ); case 'new_post_position': return __( 'Put new posts at', 'adminkeep' ); default: return ''; } } /** * Labels for a choice field, resolved at render time. * * @param string $key Field key. * @return array Value => label, in display order. */ public function field_choices( $key ) { if ( 'new_post_position' === $key ) { return array( 'top' => __( 'Top of the order', 'adminkeep' ), 'bottom' => __( 'Bottom of the order', 'adminkeep' ), ); } return array(); } /** * What switching this on actually does. * * @return string[] */ public function how_it_works() { return array( __( 'Adds a Sort entry to the admin menu of every post type you tick above. Nothing is sortable until you tick something.', 'adminkeep' ), __( 'Sorting happens on a screen of its own, 100 rows at a time: drag a row where you want it. Nothing is written until you press Save.', 'adminkeep' ), __( 'A new post goes to the top or the bottom of the order, whichever you choose above, so it never lands somewhere in the middle. A post given a position of its own keeps it.', 'adminkeep' ), __( 'The order you save is the order your site shows, for the post types you chose — the blog page included.', 'adminkeep' ), __( 'Types that nest, where one item can sit underneath another, are not offered. Their order is already decided inside each parent, so one flat order across the whole type would mean nothing.', 'adminkeep' ), __( 'The order is stored in the field WordPress already uses for it, so it stays readable if you ever remove this plugin.', 'adminkeep' ), __( 'Any post list that already asks for a specific order — newest first, alphabetical, by price — is left exactly alone. That includes WooCommerce, search results and anything a theme or another plugin sorts for itself.', 'adminkeep' ), __( 'Only your site\'s own listings are affected. Requests from apps and other plugins are never re-sorted behind their back.', 'adminkeep' ), __( 'Sorting is limited to people who can edit other users\' posts.', 'adminkeep' ), ); } /** * Register hooks. * * @param array $settings Resolved settings. * @return void */ public function boot( array $settings ) { $this->settings = $settings; add_action( 'init', array( $this, 'add_page_attributes_support' ), 20 ); add_action( 'pre_get_posts', array( $this, 'maybe_order_posts' ) ); // The SQL half of maybe_order_posts(): rows never given a position sort last, by date. add_filter( 'posts_orderby', array( $this, 'break_post_order_ties' ), 10, 2 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_action( 'admin_menu', array( $this, 'register_screen' ) ); add_action( 'admin_menu', array( $this, 'redirect_legacy_url' ) ); /* * Zero accepted arguments, deliberately. * * do_action( 'admin_init' ) is called with no arguments, and do_action() then passes a * single empty string on to every callback. A callback declared as * handle_save( $redirect = true ) would therefore be handed '' for $redirect and never * redirect — the default would be silently unreachable in production while still applying * in the tests that call the method directly. Registering with 0 accepted args makes * WP_Hook call it with no arguments at all, so the default is what actually arrives. */ add_action( 'admin_init', array( $this, 'handle_save' ), 10, 0 ); // The same save, without a page load. The form above stays as the no-JS fallback. add_action( 'rest_api_init', array( $this, 'register_rest_route' ) ); add_filter( 'wp_insert_post_data', array( $this, 'place_new_post' ), 10, 2 ); } /** * Give a brand-new post its place in the order, as part of the insert itself. * * THE MOMENT OF CREATION IS THE TRANSITION OUT OF 'auto-draft', NOT THE FIRST INSERT. Both * editors create an auto-draft row the moment the "new post" screen opens and only turn it * into a post on the first real save; placing the auto-draft would hand a slot to every * abandoned one. So a row is placed when it leaves 'auto-draft' (an update whose stored status * is auto-draft), or when it is inserted straight into a real status (a programmatic * wp_insert_post(), an import). Any later status change is not a creation. * * A menu_order that is already non-zero was chosen by somebody and is kept. Zero is read as * "unset" even when it was sent explicitly: the classic editor's Page Attributes box always * posts the field, with 0 in it, so distinguishing "typed 0" from "never touched" would * switch the feature off for exactly the screen most likely to show the box. * * 'top' goes one BELOW the current minimum rather than shifting every row up by one. The * column is a signed int, a negative value sorts exactly as intended, and it costs one * aggregate query instead of a write per row of the type. * * Done on the data filter rather than after the fact on 'transition_post_status' so the * position is written by the same UPDATE that changes the status — one write, no second * cache flush, and every later hook sees the placed value. * * @param array $data Sanitised post data about to be written. * @param array $postarr Post array as passed, merged with the stored row on an update. * @return array */ public function place_new_post( $data, $postarr = array() ) { if ( ! is_array( $data ) || empty( $data['post_type'] ) || empty( $data['post_status'] ) ) { return $data; } if ( 'auto-draft' === $data['post_status'] || ! in_array( $data['post_type'], $this->enabled_post_types(), true ) ) { return $data; } if ( isset( $data['menu_order'] ) && 0 !== (int) $data['menu_order'] ) { return $data; } $post_id = ! empty( $postarr['ID'] ) ? (int) $postarr['ID'] : 0; $previous = 'new'; if ( $post_id > 0 ) { $stored = get_post( $post_id ); // No stored row means wp_insert_post() was handed an explicit ID for a fresh insert. $previous = $stored instanceof \WP_Post ? $stored->post_status : 'new'; } if ( ! in_array( $previous, array( 'new', 'auto-draft' ), true ) ) { return $data; } $where = isset( $this->settings['new_post_position'] ) && 'top' === $this->settings['new_post_position'] ? 'top' : 'bottom'; $data['menu_order'] = ( new OrderService() )->new_post_position( $data['post_type'], $where, $post_id ); // The write happens inside wp_insert_post(), not through OrderService, so the // has-saved-order cache is told here. OrderService::forget_has_saved_order( $data['post_type'] ); return $data; } /** * Post types the user opted in, filtered to the ones still eligible. * * Filtered at read time rather than pruned in the option: a selection that became ineligible * should return if eligibility ever widens, not be destroyed behind the user's back. * * @return string[] */ public function enabled_post_types() { $stored = isset( $this->settings['post_types'] ) ? (array) $this->settings['post_types'] : array(); return array_values( array_filter( $stored, static function ( $post_type ) { $object = get_post_type_object( $post_type ); if ( ! $object instanceof \WP_Post_Type || $object->hierarchical ) { return false; } // 'attachment' is non-hierarchical but is not a content type a user "orders" — // SettingsPage::flat_post_types() hides it from the picker for the same reason. // Do not simplify this back to a bare hierarchical check. if ( 'attachment' === $object->name ) { return false; } return true; } ) ); } /** * Give opted-in non-hierarchical types the menu_order field. * * Hierarchical types already have it. Registering core's own support rather than inventing a * meta key is what keeps the order meaningful after this plugin is gone. * * @return void */ public function add_page_attributes_support() { foreach ( $this->enabled_post_types() as $post_type ) { if ( post_type_exists( $post_type ) && ! post_type_supports( $post_type, 'page-attributes' ) ) { add_post_type_support( $post_type, 'page-attributes' ); } } } /** * Apply the manual order to a query, if and only if it is one of ours. * * @param \WP_Query $query Query being prepared. * @return void */ public function maybe_order_posts( $query ) { if ( ! $query instanceof \WP_Query || ! $this->should_order( $query ) ) { return; } // A type nobody has ever sorted has no manual order to apply — every row holds 0, and // "menu_order ASC" over all-equal values is not the saved order the user asked for, it // is WordPress's date order scrambled into ID order. Ticking the box alone must change // nothing until something is actually sorted. if ( ! OrderService::has_saved_order( (string) $this->single_post_type( $query ) ) ) { return; } $query->set( 'orderby', 'menu_order' ); $query->set( 'order', 'ASC' ); // For break_post_order_ties(), which rewrites the ORDER BY this produces. $query->set( self::APPLIED_ARG, true ); } /** * The ORDER BY for a query maybe_order_posts() claimed: sorted rows first, in their saved * order; rows never given a position (menu_order 0) last, newest first. * * Without this, unsorted rows tie at 0 and sort FIRST in an unspecified order — so a post * created while the plugin was inactive jumped to the top of a carefully sorted list, and * on date-ordered ties the front page shuffled. Zero is the "never placed" value (the write * side never hands it out — see OrderService::move_posts()), so it can be recognised here. * * Only rewrites when the marker is set AND the orderby is still ours — a later * 'pre_get_posts' callback that chose its own sort keeps it. * * @param string $orderby ORDER BY clause. * @param \WP_Query $query Query being built. * @return string */ public function break_post_order_ties( $orderby, $query ) { if ( ! $query instanceof \WP_Query || true !== $query->get( self::APPLIED_ARG ) || 'menu_order' !== $query->get( 'orderby' ) ) { return $orderby; } return OrderService::order_by_sql(); } /** * The deny-by-default ladder. Read the class docblock before loosening any rung. * * @param \WP_Query $query Query being prepared. * @return bool */ private function should_order( \WP_Query $query ) { $explicit = $query->get( self::QUERY_ARG, null ); // An explicit opt-out always wins, even over everything below. if ( false === $explicit ) { return false; } // Feeds are a contract with the reader's client: newest first, always. A feed with a // manual order re-delivers old items as "new" to anything that reads top-down. if ( $query->is_feed() ) { return false; } // Rung 1: never override a query that already said how it wants to be sorted. This is the // rung that keeps WooCommerce, search relevance and every "newest first" loop intact. $orderby = $query->get( 'orderby' ); if ( ! empty( $orderby ) ) { return false; } // Rung 2: exactly one post type, and it must be one the user ticked. A query spanning // several types has no single manual order to apply. $post_type = $this->single_post_type( $query ); if ( null === $post_type || ! in_array( $post_type, $this->enabled_post_types(), true ) ) { return false; } // An explicit opt-in from the caller is honoured from here on. if ( true === $explicit ) { return true; } // Rung 3: REST and admin-ajax responses belong to whoever asked for them. Re-sorting an // app's payload is invisible from the admin and impossible to debug from outside. if ( ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || wp_doing_ajax() ) { return false; } // Rung 4: the main query only — the site's own listing, or the admin list screen where // the handles are. A secondary query is somebody's widget, slider or related-posts // block, and none of those asked for this. return $query->is_main_query(); } /** * The single post type a query targets, or null if it is not exactly one. * * @param \WP_Query $query Query being prepared. * @return string|null */ private function single_post_type( \WP_Query $query ) { $post_type = $query->get( 'post_type' ); if ( is_string( $post_type ) && '' !== $post_type && 'any' !== $post_type ) { return $post_type; } if ( is_array( $post_type ) && 1 === count( $post_type ) ) { return (string) reset( $post_type ); } // An empty post_type is 'post' on the blog and on the archives of the post's own // taxonomies, dates and authors — WP_Query only fills the default in inside get_posts(), // after 'pre_get_posts' has already run, so the posts page arrived here as "no type" and // was never sorted. Search is left out on purpose: relevance order is what it asked for. if ( empty( $post_type ) && ! $query->is_search() && $this->is_post_archive( $query ) ) { return 'post'; } return null; } /** * Whether a query with no post_type of its own is one WordPress will resolve to 'post'. * * Home (the posts page, or a front page showing posts), category, tag, date and author * archives, and a custom taxonomy archive whose taxonomy is attached to 'post' only. * * @param \WP_Query $query Query being prepared. * @return bool */ private function is_post_archive( \WP_Query $query ) { if ( $query->is_home() || $query->is_category() || $query->is_tag() || $query->is_date() || $query->is_author() ) { return true; } if ( ! $query->is_tax() ) { return false; } $queried = $query->get_queried_object(); if ( ! $queried instanceof \WP_Term ) { return false; } $taxonomy = get_taxonomy( $queried->taxonomy ); return $taxonomy instanceof \WP_Taxonomy && array( 'post' ) === array_values( (array) $taxonomy->object_type ); } /** * Load the sort-screen assets, on the sort screen and nowhere else. * * @param string $hook Current admin page hook. * @return void */ public function enqueue( $hook ) { if ( false === strpos( (string) $hook, SortScreen::page_slug() ) ) { return; } wp_enqueue_script( 'adminkeep-order', ADMINKEEP_URL . 'assets/js/order.js', array( 'jquery', 'jquery-ui-sortable', 'wp-api-fetch' ), \Adminkeep\Assets::version( 'assets/js/order.js' ), true ); // No root/nonce here: enqueuing 'wp-api-fetch' in the admin makes core print its own // root-URL and nonce middleware inline, and a second hand-installed copy of each is at // best redundant and at worst fights core's nonce refresh. wp_localize_script( 'adminkeep-order', 'adminkeepOrder', array( 'route' => RestController::NAMESPACE_V1 . '/order', 'i18n' => array( 'saving' => __( 'Saving…', 'adminkeep' ), 'saved' => __( 'Saved', 'adminkeep' ), 'save' => __( 'Save order', 'adminkeep' ), 'failed' => __( 'The order could not be saved. Nothing was changed.', 'adminkeep' ), /* translators: %s: number of rows left unmoved. */ 'denied' => __( 'Order saved, but %s of the rows were left where they were — you are not allowed to edit them.', 'adminkeep' ), 'dismiss' => __( 'Dismiss', 'adminkeep' ), ), ) ); wp_enqueue_style( 'adminkeep-order', ADMINKEEP_URL . 'assets/css/order.css', array(), \Adminkeep\Assets::version( 'assets/css/order.css' ) ); } /** * Register one Sort page per opted-in post type. * * A REAL submenu page per type, with a slug of its own (menu_slug()). Core then does * everything itself — builds the href, routes the request, opens the parent menu and marks * the entry current. A 'parent_file' filter cannot do this: wp-admin/menu-header.php applies * it and THEN calls get_admin_page_parent(), which resets $parent_file from whichever * $submenu row carries the current $plugin_page, so only a row under the right parent, with * a slug unique to the type, lands on the right menu. * * Gated on the type's own 'edit_others_posts' — the capability can_sort() demands — so a * user who would only be refused on arrival is not shown the entry at all. * * @return void */ public function register_screen() { foreach ( $this->enabled_post_types() as $post_type ) { $type = get_post_type_object( $post_type ); if ( ! $type instanceof \WP_Post_Type ) { continue; } add_submenu_page( $this->menu_parent( $post_type ), __( 'Sort', 'adminkeep' ), __( 'Sort', 'adminkeep' ), $type->cap->edit_others_posts, $this->menu_slug( $post_type ), function () use ( $post_type ) { $this->render_post_type_screen( $post_type ); } ); } } /** * The menu a post type's own screens hang off, the way wp-admin/menu.php decides it. * * 'post' is edit.php; any other type is edit.php?post_type=, unless the type asked to * be drawn under another menu by naming it in show_in_menu. * * @param string $post_type Post type. * @return string */ private function menu_parent( $post_type ) { $type = get_post_type_object( $post_type ); if ( $type instanceof \WP_Post_Type && is_string( $type->show_in_menu ) && '' !== $type->show_in_menu ) { return $type->show_in_menu; } return 'post' === $post_type ? 'edit.php' : 'edit.php?post_type=' . $post_type; } /** * The submenu slug of a post type's Sort page. * * One per type, deliberately. A shared slug under several parents is something core allows, * but get_admin_page_parent() resolves the current page to the FIRST parent holding that slug, * so every type's sort screen would open the first enabled type's menu. * * @param string $post_type Post type. * @return string */ public function menu_slug( $post_type ) { return SortScreen::page_slug() . '-' . $post_type; } /** * Render a post type's Sort page. * * The type comes from the registration, not the query string: core builds the href from the * slug and the parent, and for 'post' that is a bare edit.php?page=… with no post_type on it. * * @param string $post_type Post type. * @return void */ public function render_post_type_screen( $post_type ) { $paged = isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! in_array( $post_type, $this->enabled_post_types(), true ) ) { wp_die( esc_html__( 'That list cannot be sorted.', 'adminkeep' ) ); } if ( ! $this->can_sort( $post_type ) ) { wp_die( esc_html__( 'You are not allowed to sort this list.', 'adminkeep' ), '', 403 ); } $this->screen()->render( $post_type, $paged ); } /** * Send a leftover admin.php?page=adminkeep-order link to the page that replaced it. * * That page no longer exists: &post_type=X goes to X's own Sort page; anything else — a * &taxonomy= link included, from the era this plugin sorted terms — is told the list cannot * be sorted. On 'admin_menu' because that fires inside wp-admin/menu.php, ahead * of the page-access check that would otherwise refuse the unregistered page. * * @return void */ public function redirect_legacy_url() { global $pagenow; $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( 'admin.php' !== $pagenow || SortScreen::page_slug() !== $page ) { return; } $post_type = isset( $_GET['post_type'] ) ? sanitize_key( wp_unslash( $_GET['post_type'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $paged = isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended // A &taxonomy= link is from the era this plugin sorted terms; nothing offers that now, // so it is ineligible like any other subject without a page. if ( isset( $_GET['taxonomy'] ) || ! in_array( $post_type, $this->enabled_post_types(), true ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended wp_die( esc_html__( 'That list cannot be sorted.', 'adminkeep' ) ); } $url = $this->sort_url( $post_type ); if ( $paged > 1 ) { $url = add_query_arg( 'paged', $paged, $url ); } wp_safe_redirect( $url ); exit; } /** * The screen renderer. * * @return SortScreen */ private function screen() { return new SortScreen( new OrderService() ); } /** * URL of a post type's Sort page. * * The same address core prints in the menu — a page hung off a wp-admin file (edit.php, * edit.php?post_type=…) is that file plus ?page=, one hung off a plugin menu is * admin.php?page=. Built here the way wp-admin/menu-header.php builds it rather than through * menu_page_url(), which only knows a page once 'admin_menu' has run and this is also needed * from tests and from the save handler. * * @param string $post_type Post type whose page it is. * @return string */ public function sort_url( $post_type ) { $parent = $this->menu_parent( $post_type ); $file = strtok( $parent, '?' ); if ( file_exists( ABSPATH . 'wp-admin/' . $file ) ) { return admin_url( add_query_arg( 'page', $this->menu_slug( $post_type ), $parent ) ); } return add_query_arg( 'page', $this->menu_slug( $post_type ), admin_url( 'admin.php' ) ); } /** * Whether the current user may sort a post type. * * 'edit_others_posts', not 'edit_posts', and the SUBJECT'S OWN one. This screen lists every * id ordered_post_ids() returns — publish, future, draft, pending AND private, with no * 'perm' argument, so WP_Query merges private statuses in unconditionally and never scopes * to the author. That is the titles of everybody else's drafts and scheduled posts, on a * screen edit.php would never have shown a Contributor. It also governs the write, so an * Author could otherwise initiate a reorder of the whole set. It is the same capability the * Abilities path requires for the identical operation (ability_can_reorder()), and the human * path has no business being looser than the machine one. move_posts() still re-checks * 'edit_post' row by row underneath; this is the coarse gate, not the whole of it. * * @param string $post_type Post type. * @return bool */ private function can_sort( $post_type ) { $type = get_post_type_object( $post_type ); return $type instanceof \WP_Post_Type && current_user_can( $type->cap->edit_others_posts ); } /** * Apply a submitted order. * * THE ONLY PLACE THIS PLUGIN TURNS AN UNAUTHENTICATED POST INTO A WRITE. Nonce, then * eligibility, then capability, then sanitised input — in that order, and none of them are * optional. A nonce proves the request was intended; it proves nothing about permission. * * POSITIONS ARE PASSED THROUGH UNCHANGED. The submitted numbers are already the 1-based * positions move_posts() expects; it subtracts the 1 itself, inside * apply_moves(). Converting would double-convert and drag every row of the submitted page * one slot towards the front of the list — which is the cross-page corruption this whole * screen exists to fix. An unmodified page-2 submit must be a fixed point that writes nothing. * * ONLY THE ROWS THAT CHANGED ARE REQUESTS. The form posts every visible row, so a request map * built from all of them says "and every other row wants to stay exactly where it is" — which * is a claim on every slot on the page. apply_moves() then resolves the collision between the * mover and the incumbent by submission order, the incumbent comes first in DOM order, and the * mover is packed one slot past where it was dropped. Comparing each position against the * baseline SortScreen rendered for that row — see render_row() — reduces the map to the rows * the user actually moved; that comparison lives in save_order(), shared with the REST route. * * The baseline is POST data and is trusted no further than the positions are. Forging one can * only make a row look changed or unchanged; the nonce, the eligibility check and can_sort() * have all already run above. * * @param bool $redirect Whether to redirect afterwards. False in tests. * @return void */ public function handle_save( $redirect = true ) { if ( ! isset( $_POST['adminkeep_order'], $_POST['adminkeep_order_subject'] ) ) { return; } // The subject is read before the nonce is verified because the nonce action is scoped to it. // That is not a trust decision: sanitize_key() makes it a safe string, and naming the wrong // subject only produces a nonce action nobody can hold a valid nonce for. $subject = sanitize_key( wp_unslash( $_POST['adminkeep_order_subject'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing check_admin_referer( SortScreen::nonce_action( $subject ) ); if ( ! in_array( $subject, $this->enabled_post_types(), true ) ) { return; } if ( ! $this->can_sort( $subject ) ) { return; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- values are validated and cast inside save_order(). $baselines = isset( $_POST['adminkeep_order_base'] ) ? (array) wp_unslash( $_POST['adminkeep_order_base'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- same. $positions = (array) wp_unslash( $_POST['adminkeep_order'] ); $this->save_order( $subject, $positions, $baselines, $denied ); if ( $redirect ) { $paged = isset( $_POST['paged'] ) ? absint( wp_unslash( $_POST['paged'] ) ) : 1; $url = $this->sort_url( $subject ); // Back to the page the form was submitted from. Landing on page 1 after saving page 7 // looks exactly like the save was thrown away. if ( $paged > 1 ) { $url = add_query_arg( 'paged', $paged, $url ); } $url = add_query_arg( 'updated', '1', $url ); // A save that skipped rows must not be reported as a plain success: the stored order // no longer matches the screen the user arranged. SortScreen::render() turns this into // the warning half of its notice. if ( $denied > 0 ) { $url = add_query_arg( 'adminkeep_denied', (int) $denied, $url ); } wp_safe_redirect( $url ); exit; } } /** * Apply a submitted page of positions. The one place both save paths — the form and the REST * route — go through, after their own nonce, eligibility and capability checks. * * The submitted numbers are the 1-based positions move_posts() expects; they * are not converted here. A row is a request only where its position differs from the * baseline the screen rendered for it (see SortScreen::render_row()); a row with no baseline * is treated as changed, the conservative direction. An unreadable position leaves its row * alone rather than reading as 0 and clamping to the front. * * @param string $post_type Post type, already checked. * @param array $positions Map of id => submitted position. * @param array $baselines Map of id => the position the screen rendered. * @param int $denied Set to the number of rows skipped for lack of capability. * @param int[] $resolved Set to the full resolved order — see OrderService::move_posts(). * @return int Rows written. */ public function save_order( $post_type, array $positions, array $baselines, &$denied = null, &$resolved = null ) { $requested = array(); foreach ( $positions as $id => $position ) { $id = (int) $id; if ( ! is_scalar( $position ) || ! is_numeric( trim( (string) $position ) ) ) { continue; } $position = absint( $position ); if ( isset( $baselines[ $id ] ) && is_scalar( $baselines[ $id ] ) && absint( $baselines[ $id ] ) === $position ) { continue; } $requested[ $id ] = $position; } return (int) ( new OrderService() )->move_posts( $post_type, $requested, $denied, $resolved ); } /** * Register the save route. * * @return void */ public function register_rest_route() { register_rest_route( RestController::NAMESPACE_V1, '/order', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_save_order' ), 'permission_callback' => array( $this, 'rest_can_save_order' ), 'args' => array( 'subject' => array( 'type' => 'string', 'required' => true, ), 'moves' => array( 'type' => 'object', 'required' => true, ), 'base' => array( 'type' => 'object', 'default' => array(), ), 'paged' => array( 'type' => 'integer', 'default' => 1, 'minimum' => 1, ), ), ) ); } /** * The same gate as handle_save(): an opted-in subject, and the subject's own capability. * * Cookie-authenticated REST requests have already had their 'wp_rest' nonce checked by core * (rest_cookie_check_errors()) before this runs; a missing or wrong nonce never reaches here * as a logged-in user. * * @param \WP_REST_Request $request Request. * @return bool|\WP_Error */ public function rest_can_save_order( $request ) { $subject = sanitize_key( (string) $request->get_param( 'subject' ) ); if ( ! in_array( $subject, $this->enabled_post_types(), true ) ) { return new \WP_Error( 'adminkeep_order_not_sortable', __( 'That list cannot be sorted.', 'adminkeep' ), array( 'status' => 404 ) ); } if ( ! $this->can_sort( $subject ) ) { return new \WP_Error( 'adminkeep_order_forbidden', __( 'You are not allowed to sort this list.', 'adminkeep' ), array( 'status' => rest_authorization_required_code() ) ); } return true; } /** * Save a page of positions and hand back the page as it now stands. * * 'positions' is id => absolute position for every row on the requested page, in stored * order — plus any submitted row that resolved to a slot off that page — so the client can * renumber and reset its baselines without a reload. The order comes back from move_posts() * itself rather than a second full-set read. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function rest_save_order( $request ) { $subject = sanitize_key( (string) $request->get_param( 'subject' ) ); $moves = $request->get_param( 'moves' ); $base = $request->get_param( 'base' ); $paged = max( 1, (int) $request->get_param( 'paged' ) ); $written = $this->save_order( $subject, is_array( $moves ) ? $moves : array(), is_array( $base ) ? $base : array(), $denied, $resolved ); $offset = SortScreen::page_offset( $paged ); $resolved = (array) $resolved; $positions = array(); foreach ( array_slice( $resolved, $offset, SortScreen::PER_PAGE ) as $index => $id ) { $positions[ (int) $id ] = $offset + $index + 1; } // A submitted row can resolve to a slot outside the requested page. Without its stored // position in the response, the client would keep that row's stale position/baseline // pair on screen and submit a phantom move on the next save. if ( is_array( $moves ) ) { $slots = array_flip( array_map( 'intval', $resolved ) ); foreach ( array_keys( $moves ) as $id ) { $id = (int) $id; if ( ! isset( $positions[ $id ] ) && isset( $slots[ $id ] ) ) { $positions[ $id ] = $slots[ $id ] + 1; } } } return rest_ensure_response( array( 'saved' => true, 'written' => $written, 'denied' => $denied, 'positions' => $positions, ) ); } /** * Offer reordering a post type to the Abilities API. * * Posts only — which is all the feature itself sorts. * * @return array> */ public function abilities() { return array( 'reorder-posts' => array( 'label' => __( 'Reorder posts', 'adminkeep' ), 'description' => __( 'Sets the manual order of a post type from a list of post IDs, first to last. Only the posts whose position actually changes are written, and the order applies to front-end queries as well as the admin list.', 'adminkeep' ), 'input_schema' => array( 'type' => 'object', 'properties' => array( 'post_type' => array( 'type' => 'string', 'description' => __( 'The post type being ordered. It must be one the site owner enabled ordering for.', 'adminkeep' ), ), 'post_ids' => array( 'type' => 'array', 'description' => __( 'Post IDs in their new order, first to last.', 'adminkeep' ), 'items' => array( 'type' => 'integer' ), ), ), 'required' => array( 'post_type', 'post_ids' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'written' => array( 'type' => 'integer', 'description' => __( 'How many posts moved.', 'adminkeep' ), ), ), ), 'permission_callback' => array( $this, 'ability_can_reorder' ), 'execute_callback' => array( $this, 'ability_reorder' ), 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, // Applying the same order twice writes nothing the second time. 'idempotent' => true, ), ), ), ); } /** * Whether the current user may reorder the requested post type. * * The service checks edit_post per row and skips what the user cannot touch; this is the * coarser gate that stops the call being worth making at all. * * @param array $input Ability input. * @return bool */ public function ability_can_reorder( $input = array() ) { $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; if ( ! in_array( $post_type, $this->enabled_post_types(), true ) ) { return false; } $type = get_post_type_object( $post_type ); if ( null === $type ) { return false; } return current_user_can( $type->cap->edit_others_posts ); } /** * Write a new order. * * The ability's contract is still "post IDs, first to last" — that shipped and is not * changing. What changed underneath is OrderService: it no longer takes a slice index as a * position, so a full list is turned into absolute positions (first id => 1, second => 2, …) * before it is handed to move_posts(). For a full list that is identical to the old * behaviour. For a partial list it is now a well-defined "move these ids to the front", not * the old "overwrite the front of the set with whatever was submitted" — which was the * corruption bug this class exists to fix. * * @param array $input Ability input. * @return array */ public function ability_reorder( $input = array() ) { $post_type = isset( $input['post_type'] ) ? (string) $input['post_type'] : ''; $post_ids = isset( $input['post_ids'] ) ? array_map( 'absint', (array) $input['post_ids'] ) : array(); $requested = array(); $position = 1; foreach ( $post_ids as $post_id ) { $requested[ $post_id ] = $position++; } $written = ( new OrderService() )->move_posts( $post_type, $requested ); return array( 'written' => (int) $written ); } }