posts}.menu_order = 0) ASC, {$wpdb->posts}.menu_order ASC, {$wpdb->posts}.post_date DESC, {$wpdb->posts}.ID DESC"; } /** * Every id of a type, in the order the site shows them — what the Sort screen renders and * what positions are computed against. The ORDER BY is order_by_sql(), shared with the front * end, so the two cannot drift apart again. * * It has to return the WHOLE set: an absolute position computed against a narrowed or * re-sorted list is wrong for every row after the narrowing, so anything that quietly * changes which ids come back corrupts the screen. A direct query is used for exactly that * reason: get_posts() suppresses the 'posts_*' filters but never 'pre_get_posts', which fires * before the suppress_filters guard, so a third-party callback narrowing admin queries could * shrink the set and apply_moves() would then rewrite menu_order for a subset of the type. * Nothing here is cached: the screen reads it once per render and move_posts() once per save * (twice when rows were denied and the stored order has to be re-read). * * @param string $post_type Post type. * @return int[] */ public function ordered_post_ids( $post_type ) { global $wpdb; $stati = self::orderable_post_stati(); if ( empty( $stati ) ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $stati ), '%s' ) ); $order_by = self::order_by_sql(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared $ids = $wpdb->get_col( $wpdb->prepare( // $placeholders is a run of literal '%s' sized from count( $stati ); $order_by is // the constant clause from order_by_sql() — neither carries input. "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status IN ( {$placeholders} ) ORDER BY {$order_by}", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared array_merge( array( $post_type ), $stati ) ) ); return array_map( 'intval', $ids ); } /** * The post statuses whose rows belong to the ordered set. * * Every registered non-internal status except trash — for core that is exactly publish, * future, draft, pending and private, which an earlier version hardcoded. The dynamic list * is what keeps a post parked in a plugin-registered status (an editorial-workflow stage, an * "archived" state) inside the total order: hardcoding dropped such posts from the screen * AND from apply_moves()' arithmetic, so their stale menu_order collided with freshly * assigned positions when they later returned to publish. Internal statuses (auto-draft, * inherit) are working storage, not content; trash is on its way out and rejoins the order * on restore. * * @return string[] */ private static function orderable_post_stati() { return array_values( array_diff( get_post_stati( array( 'internal' => false ) ), array( 'trash' ) ) ); } /** * Move posts to absolute positions. * * Dragging one row a short distance in a large list should cost a couple of updates, not a * hydrated WP_Post object and a capability check for every row in the set. The current * menu_order for the whole ordered set is fetched in a single query; a row whose value * already matches its new position is never touched again — no get_post(), no * current_user_can(), nothing. A post_type mismatch is not re-checked per row here: the ids * this method ever sees already came out of ordered_post_ids( $post_type ), which is a * type-filtered query, so nothing outside that type can appear in $requested and survive * apply_moves()'s membership check. * * @param string $post_type Post type. * @param array $requested Map of post id => 1-based absolute position. * @param int|null $denied Out: rows that needed a new position but were withheld by the * per-row 'edit_post' check. A caller reporting "saved" owes the * user this number — a nonzero count means the stored order no * longer matches the screen the user just arranged. * @param int[] $resolved Out: the full order as actually stored, first to last, so a * caller reporting the result does not re-read the set it just * wrote. Re-read internally only when rows were denied, since * the stored order then differs from the intended one — a re-read paid on that path only. * @return int Rows written. */ public function move_posts( $post_type, array $requested, &$denied = null, &$resolved = null ) { global $wpdb; $denied = 0; $resolved = $this->apply_moves( $this->ordered_post_ids( $post_type ), $requested ); $ordered = $resolved; if ( empty( $ordered ) ) { return 0; } $placeholders = implode( ',', array_fill( 0, count( $ordered ), '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared $rows = $wpdb->get_results( $wpdb->prepare( // $placeholders is a comma-joined run of literal '%d' built from count( $ordered ) // a few lines up — never from input — and $ordered supplies exactly that many // replacements. Both sniffs below are blind to an IN list assembled that way: // InterpolatedNotPrepared sees a variable in the SQL, and UnfinishedPrepare sees // replacements with no placeholder it can recognise. The query is fully prepared. "SELECT ID, menu_order FROM $wpdb->posts WHERE ID IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $ordered ) ); $current = array(); // $wpdb->get_results() returns null on a query error rather than an empty array; casting // guards the loop and leaves $current empty, so every row is skipped — a silent no-write, // not a PHP warning. foreach ( (array) $rows as $row ) { $current[ (int) $row->ID ] = (int) $row->menu_order; } // STORED 1-BASED. Slot 0 of the resolved order is menu_order 1, so a sorted row never // holds 0 — the value WordPress gives every post it has never placed, and the value // Order::place_new_post() reads as "unset". Storing 0-based (as an earlier version did) // made the first sorted row indistinguishable from an unplaced one. $to_write = array(); foreach ( $ordered as $slot => $post_id ) { $position = $slot + 1; if ( isset( $current[ $post_id ] ) && $current[ $post_id ] !== $position ) { $to_write[ $post_id ] = $position; } } if ( empty( $to_write ) ) { return 0; } // current_user_can( 'edit_post' ) resolves through get_post(), one uncached SELECT per // row when the ids came from the raw query above. One prime for exactly the rows about // to be checked turns that into a single query. _prime_post_caches( array_keys( $to_write ), false, false ); $written = 0; foreach ( $to_write as $post_id => $position ) { if ( ! current_user_can( 'edit_post', $post_id ) ) { ++$denied; continue; } // wp_update_post() would fire the whole save stack — revisions, save_post, the // works — for a column that is pure presentation. This writes the column. $this->write_menu_order( $post_id, $position ); ++$written; } if ( $written > 0 ) { self::forget_has_saved_order( $post_type ); } // $resolved so far is the INTENDED order. When rows were withheld, what is stored // differs from it, and a caller reporting positions back to a screen must not report // intent as fact — one re-read, paid only on the denied path, makes it the truth. if ( $denied > 0 ) { $resolved = $this->ordered_post_ids( $post_type ); } return $written; } /** * Apply requested absolute positions to a full ordered list. * * Pure array arithmetic, no storage, and — unlike an earlier version of this method — a * fixed point: positions are resolved entirely against the ORIGINAL list and the requested * map, never against a list that earlier moves in this same call have already changed. * Applying the same request twice, or requesting an order the set is already in, always * produces the same result and therefore writes nothing the second time. (The earlier * version spliced each move into the list as it went, so a later move could silently * displace an id an earlier move had just placed — resubmitting the identical request could * change the order again. That was a bug, not a rounding artefact.) * * Every requested position is clamped into [1, N] first, where N is the size of the full * set — this is "positions ≤ 0 clamp to the front, positions > N clamp to the end". A * requested id is then assigned a 0-based slot as close to its clamped position as the rest * of the request allows: * * - Requested ids are ordered by (clamped position ascending, then the order they appear in * $requested — first submitted wins ties). This order is fixed by the request alone, never * by the current arrangement, which is what makes the result a fixed point. * - Walking that order, each id's slot is at least its own clamped target and at least one * past the slot just given to the id before it — so two ids that asked for the same (or an * overlapping) position are packed into consecutive slots, first-submitted first, instead * of one silently overwriting the other. * PHP's sort functions are only guaranteed stable from PHP 8.0, and this plugin supports * 7.4, so the tie-break is an explicit secondary sort key, not reliance on sort stability. * - If that packing runs off the end of the list — every clamped position is already ≤ N, but * several ids piled at the tail can still collectively need more room than is left — the * tail is pulled back just far enough, working backward from the last id, to fit; the * ordering established above (first-submitted nearer the front) is preserved. * - Ids that were not requested keep their existing relative order and fill whatever slots * the requested ids did not claim. * * @param int[] $ordered Current full order. * @param array $requested Map of id => 1-based absolute position. * @return int[] The new full order, indexed 0..n-1. */ private function apply_moves( array $ordered, array $requested ) { $ordered = array_values( array_map( 'intval', $ordered ) ); $total = count( $ordered ); if ( 0 === $total ) { return $ordered; } $present = array_flip( $ordered ); $moves = array(); $sequence = 0; foreach ( $requested as $id => $position ) { $id = (int) $id; if ( ! isset( $present[ $id ] ) ) { continue; } $moves[] = array( 'id' => $id, 'position' => max( 1, min( $total, (int) $position ) ), 'sequence' => $sequence++, ); } // A comparator with a unique secondary key (sequence) never leaves a true tie for the // sort algorithm to resolve on its own, so its stability (or lack of it) cannot matter. usort( $moves, static function ( $a, $b ) { if ( $a['position'] !== $b['position'] ) { return $a['position'] <=> $b['position']; } return $a['sequence'] <=> $b['sequence']; } ); $count = count( $moves ); $slot = -1; // Forward pass: each id gets at least its own clamped target, and at least one slot past // whichever id was placed immediately before it in this order. foreach ( $moves as $index => $move ) { $slot = max( $move['position'] - 1, $slot + 1 ); $moves[ $index ]['slot'] = $slot; } // Backward pass: nothing can land past the end of the list, so anything the forward pass // pushed off the tail is pulled back in just far enough to leave room for whatever still // has to follow it — without disturbing the order the forward pass already established. $ceiling = $total - 1; for ( $index = $count - 1; $index >= 0; $index-- ) { $moves[ $index ]['slot'] = min( $moves[ $index ]['slot'], $ceiling ); $ceiling = $moves[ $index ]['slot'] - 1; } $result = array_fill( 0, $total, null ); foreach ( $moves as $move ) { $result[ $move['slot'] ] = $move['id']; } $remaining = array_values( array_diff( $ordered, array_column( $moves, 'id' ) ) ); foreach ( $result as $slot => $id ) { if ( null === $id ) { $result[ $slot ] = array_shift( $remaining ); } } return $result; } /** * The lowest menu_order in a type's ordered set, or null if the set is empty. * * @param string $post_type Post type. * @param int $exclude A post id to leave out — the one being placed, typically. * @return int|null */ public function min_position( $post_type, $exclude = 0 ) { return $this->edge_position( 'MIN', $post_type, $exclude ); } /** * The highest menu_order in a type's ordered set, or null if the set is empty. * * @param string $post_type Post type. * @param int $exclude A post id to leave out. * @return int|null */ public function max_position( $post_type, $exclude = 0 ) { return $this->edge_position( 'MAX', $post_type, $exclude ); } /** * The position a new post takes at one end of a type's order. * * 'bottom' is one past the current maximum; an empty set starts at 1, leaving 0 to mean * "never placed" (and 0 is never handed out, in either direction). 'top' is one BELOW the current minimum — menu_order is a signed int, so a * negative value sorts first exactly as intended, and it is one aggregate query rather than an * UPDATE across every row of the type. * * @param string $post_type Post type. * @param string $where 'top' or 'bottom'. * @param int $exclude The post being placed, so its own 0 does not count. * @return int */ public function new_post_position( $post_type, $where, $exclude = 0 ) { if ( 'top' === $where ) { $min = $this->min_position( $post_type, $exclude ); if ( null === $min ) { return 1; } // Never 0: that is the "never placed" value, and a sorted set starts at 1, so the // first post placed on top of one skips straight to -1. return 1 === $min ? -1 : $min - 1; } $max = $this->max_position( $post_type, $exclude ); if ( null === $max ) { return 1; } // Same rule as 'top': never hand out 0, the "never placed" value — a set whose maximum // is -1 (a lone top-placed row) continues at 1, not 0. return -1 === $max ? 1 : $max + 1; } /** * Whether any row of a type holds a saved position at all. * * The read path keeps its hands off a type nobody has ever sorted — menu_order ASC over a * set that is all zeroes is not an order, it is a scramble of WordPress's date order. Cached * (the object cache is persistent on some sites), invalidated by this class's own writes and * by Order::place_new_post(); a short expiry is the safety net for anything else that writes * menu_order behind our back (quick edit, another plugin). * * @param string $post_type Post type. * @return bool */ public static function has_saved_order( $post_type ) { global $wpdb; $cached = wp_cache_get( 'has_order_' . $post_type, 'adminkeep_order' ); if ( false !== $cached ) { return 'yes' === $cached; } $stati = self::orderable_post_stati(); if ( empty( $stati ) ) { return false; } $placeholders = implode( ', ', array_fill( 0, count( $stati ), '%s' ) ); // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber $found = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( "SELECT 1 FROM {$wpdb->posts} WHERE post_type = %s AND menu_order <> 0 AND post_status IN ( {$placeholders} ) LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared array_merge( array( $post_type ), $stati ) ) ); // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber wp_cache_set( 'has_order_' . $post_type, $found ? 'yes' : 'no', 'adminkeep_order', 5 * MINUTE_IN_SECONDS ); return (bool) $found; } /** * Forget whether a type has a saved order — call after anything that writes menu_order. * * @param string $post_type Post type. * @return void */ public static function forget_has_saved_order( $post_type ) { wp_cache_delete( 'has_order_' . $post_type, 'adminkeep_order' ); } /** * MIN or MAX of menu_order across a type's ordered set — the same statuses * ordered_post_ids() lists, so trash and auto-drafts do not widen the range. * * @param string $aggregate 'MIN' or 'MAX'. * @param string $post_type Post type. * @param int $exclude A post id to leave out, 0 for none. * @return int|null */ private function edge_position( $aggregate, $post_type, $exclude ) { global $wpdb; $stati = self::orderable_post_stati(); if ( empty( $stati ) ) { return null; } $placeholders = implode( ', ', array_fill( 0, count( $stati ), '%s' ) ); $function = 'MIN' === $aggregate ? 'MIN' : 'MAX'; // $function is one of two literals chosen above and $placeholders is built from '%s' // only; every value goes through prepare(). The sniff cannot count placeholders it // cannot see, hence the disabled placeholder count. // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber $value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( "SELECT {$function}( menu_order ) FROM {$wpdb->posts} WHERE post_type = %s AND ID <> %d AND post_status IN ( {$placeholders} )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared array_merge( array( $post_type, (int) $exclude ), $stati ) ) ); // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber return null === $value ? null : (int) $value; } /** * Set a post's menu_order without running the save stack. * * @param int $post_id Post id. * @param int $position New position. * @return void */ private function write_menu_order( $post_id, $position ) { global $wpdb; $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->posts, array( 'menu_order' => (int) $position ), array( 'ID' => (int) $post_id ), array( '%d' ), array( '%d' ) ); clean_post_cache( $post_id ); } }