| @@ -1,10 +1,15 @@ | ||
| 1 | 1 | <?php |
| 2 | - | |
| 2 | +// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- core docs taxonomy REST endpoints; meta filtering required. | |
| 3 | +// phpcs:disable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- endpoint exposes user-driven exclusion. | |
| 4 | +// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table identifiers (WP-provided) and dynamic %s placeholders are intentional. | |
| 5 | +// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- the uncategorized-docs scan needs raw SQL because WP_Query has no NOT-IN-via-subquery primitive. | |
| 6 | +// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- list is rebuilt per-request from live post/term state; cache would mask uncategorized status changes. | |
| 3 | 7 | namespace WPDeveloper\BetterDocs\REST; |
| 4 | 8 | |
| 5 | 9 | use stdClass; |
| 6 | 10 | use WPDeveloper\BetterDocs\Core\BaseAPI; |
| 11 | +use WPDeveloper\BetterDocs\Utils\Helper; | |
| 7 | 12 | |
| 8 | 13 | class DocCategories extends BaseAPI { |
| 9 | 14 | public function permission_check(): bool { |
| 10 | 15 | return true; |
| @@ -13,9 +18,9 @@ | ||
| 13 | 18 | |
| 14 | 19 | public function register() { |
| 15 | 20 | $this->get( 'doc-categories', array( $this, 'get_response' ), array( |
| 16 | 21 | 'password' => array( |
| 17 | - 'description' => __( 'The password for password-protected docs.' ), | |
| 22 | + 'description' => __( 'The password for password-protected docs.', 'betterdocs' ), | |
| 18 | 23 | 'type' => 'string' |
| 19 | 24 | ) |
| 20 | 25 | ) ); |
| 21 | 26 | $this->get( 'doc-categories-kb', array( $this, 'doc_categories_kb_response' ) ); |
| @@ -58,8 +63,39 @@ | ||
| 58 | 63 | |
| 59 | 64 | return $terms; |
| 60 | 65 | } |
| 61 | 66 | |
| 67 | + /** | |
| 68 | + * Reorder a list of doc rows according to a saved id sequence. | |
| 69 | + * Ids in $saved_order keep that order; ids not present are appended. | |
| 70 | + * | |
| 71 | + * @param array $docs Doc data rows (each has an 'id' key). | |
| 72 | + * @param array $saved_order Ordered list of post ids from `_docs_order_<lang>`. | |
| 73 | + * @return array | |
| 74 | + */ | |
| 75 | + private function sort_by_saved_order( $docs, $saved_order ) { | |
| 76 | + if ( empty( $docs ) || empty( $saved_order ) ) { | |
| 77 | + return $docs; | |
| 78 | + } | |
| 79 | + | |
| 80 | + $saved_order = array_map( 'intval', (array) $saved_order ); | |
| 81 | + $position = array_flip( $saved_order ); | |
| 82 | + $tail_index = count( $saved_order ); | |
| 83 | + | |
| 84 | + $sorted = $docs; | |
| 85 | + usort( $sorted, function ( $a, $b ) use ( $position, &$tail_index ) { | |
| 86 | + $a_id = isset( $a['id'] ) ? (int) $a['id'] : 0; | |
| 87 | + $b_id = isset( $b['id'] ) ? (int) $b['id'] : 0; | |
| 88 | + | |
| 89 | + $a_pos = isset( $position[ $a_id ] ) ? $position[ $a_id ] : PHP_INT_MAX; | |
| 90 | + $b_pos = isset( $position[ $b_id ] ) ? $position[ $b_id ] : PHP_INT_MAX; | |
| 91 | + | |
| 92 | + return $a_pos <=> $b_pos; | |
| 93 | + } ); | |
| 94 | + | |
| 95 | + return $sorted; | |
| 96 | + } | |
| 97 | + | |
| 62 | 98 | private function convert_terms_to_array_of_std_objects( $payload ) { |
| 63 | 99 | $terms = array(); |
| 64 | 100 | |
| 65 | 101 | foreach ( $payload as $term ) { |
| @@ -158,9 +194,9 @@ | ||
| 158 | 194 | $posts = betterdocs()->query->get_posts( $query_args, true ); |
| 159 | 195 | $response[ $term->term_id ] = array(); |
| 160 | 196 | |
| 161 | 197 | if ( ! $posts->have_posts() ) { |
| 162 | - wp_reset_query(); | |
| 198 | + wp_reset_postdata(); | |
| 163 | 199 | } |
| 164 | 200 | while ( $posts->have_posts() ): |
| 165 | 201 | $posts->the_post(); |
| 166 | 202 | $post_obj = get_post( get_the_ID() ); |
| @@ -177,22 +213,35 @@ | ||
| 177 | 213 | array_push( $response[ $term->term_id ], $data ); |
| 178 | 214 | endwhile; |
| 179 | 215 | |
| 180 | 216 | wp_reset_postdata(); |
| 181 | - wp_reset_query(); | |
| 217 | + wp_reset_query(); // phpcs:ignore WordPress.WP.DiscouragedFunctions.wp_reset_query_wp_reset_query -- explicit global WP_Query reset after a custom loop; wp_reset_postdata() above only restores post data. | |
| 218 | + | |
| 219 | + // WP_Query's `orderby=post__in` is stripped by some plugins/filters | |
| 220 | + // (notably WPML on REST requests), so apply the saved order in PHP | |
| 221 | + // here as the source of truth. Posts not in the saved order are | |
| 222 | + // appended at the end in their existing query order. | |
| 223 | + $response[ $term->term_id ] = $this->sort_by_saved_order( | |
| 224 | + $response[ $term->term_id ], | |
| 225 | + betterdocs()->query->get_docs_order_by_terms( $term->term_id ) | |
| 226 | + ); | |
| 182 | 227 | } |
| 183 | 228 | |
| 184 | 229 | /** |
| 185 | 230 | * Uncategories Docs |
| 186 | 231 | */ |
| 187 | - // Build secure query for uncategorized docs with proper post status filtering | |
| 232 | + // Build secure query for uncategorized docs with proper post status filtering. | |
| 233 | + // $wpdb->posts / $wpdb->term_relationships / $wpdb->term_taxonomy are WP-provided | |
| 234 | + // table identifiers (safe to interpolate). Dynamic %s placeholder count is built | |
| 235 | + // from a fixed-shape $post_status array. | |
| 188 | 236 | $post_status_placeholders = implode( ',', array_fill( 0, count( $post_status ), '%s' ) ); |
| 189 | - $_post__not_in_query = $wpdb->prepare( | |
| 237 | + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- WP table identifiers; placeholders dynamically generated to match $post_status size. | |
| 238 | + $_post__not_in_query = $wpdb->prepare( | |
| 190 | 239 | "SELECT ID as post_id from $wpdb->posts WHERE post_type = %s AND post_status IN ($post_status_placeholders) AND post_status != 'trash' AND post_status != 'auto-draft' AND ID NOT IN ( SELECT object_id as post_id FROM $wpdb->term_relationships WHERE term_taxonomy_id IN ( SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE taxonomy = %s ) )", |
| 191 | 240 | array_merge( array( 'docs' ), $post_status, array( 'doc_category' ) ) |
| 192 | 241 | ); |
| 193 | 242 | |
| 194 | - $_post__not_in = $wpdb->get_col( $_post__not_in_query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared | |
| 243 | + $_post__not_in = $wpdb->get_col( $_post__not_in_query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query is prepared above. | |
| 195 | 244 | |
| 196 | 245 | if ( ! empty( $_post__not_in ) ) { |
| 197 | 246 | $uncategorized_docs = array(); |
| 198 | 247 | $uncategorized_query_args = array( |
| @@ -208,9 +257,9 @@ | ||
| 208 | 257 | |
| 209 | 258 | $_uncategorized_docs_query = new \WP_Query( $uncategorized_query_args ); |
| 210 | 259 | |
| 211 | 260 | if ( ! $_uncategorized_docs_query->have_posts() ) { |
| 212 | - wp_reset_query(); | |
| 261 | + wp_reset_postdata(); | |
| 213 | 262 | } |
| 214 | 263 | while ( $_uncategorized_docs_query->have_posts() ): |
| 215 | 264 | $_uncategorized_docs_query->the_post(); |
| 216 | 265 | $post_obj = get_post( get_the_ID() ); |
| @@ -227,9 +276,9 @@ | ||
| 227 | 276 | array_push( $uncategorized_docs, $data ); |
| 228 | 277 | endwhile; |
| 229 | 278 | |
| 230 | 279 | wp_reset_postdata(); |
| 231 | - wp_reset_query(); | |
| 280 | + wp_reset_postdata(); | |
| 232 | 281 | |
| 233 | 282 | $response[ 'uncategorized' ] = $uncategorized_docs; |
| 234 | 283 | } |
| 235 | 284 | |