logger = $logger; } /** * Register the routes under wppilot/v1. * * @param callable $permission The backend callback-token check. */ public function register_routes( callable $permission ): void { register_rest_route( 'wppilot/v1', '/posts/discussion', array( array( 'methods' => 'GET', 'callback' => array( $this, 'rest_read' ), 'permission_callback' => $permission, 'args' => array( 'ids' => array( 'required' => true, 'validate_callback' => static fn( $value ): bool => is_array( self::parse_ids( $value ) ) && array() !== self::parse_ids( $value ), ), ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'rest_set' ), 'permission_callback' => $permission, ), ) ); } /** * GET /posts/discussion — each named post's discussion settings. * * @param \WP_REST_Request $request `ids`: comma-separated post ids, at most 200. */ public function rest_read( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $ids = self::parse_ids( $request->get_param( 'ids' ) ); if ( null === $ids || array() === $ids ) { return self::bad_request( sprintf( 'ids must list 1 to %d posts.', self::MAX_ITEMS ) ); } $posts = array(); foreach ( $ids as $id ) { $post = get_post( $id ); if ( ! $post instanceof \WP_Post || null !== Post_Storage::refusal( $post ) || ! current_user_can( 'edit_post', $id ) ) { continue; } $posts[] = self::describe( $post ); } return new \WP_REST_Response( array( 'posts' => $posts ), 200 ); } /** * POST /posts/discussion — move each listed post from `from` to `to`, only * while it is still in `from`. * * Per post, one of: `changed`, `skipped` (no longer in `from`; its current * settings are reported), `not_found`, `forbidden` or `failed` (WordPress * did not end up with `to`). * * @param \WP_REST_Request $request `changes`: list of {id, from, to}. */ public function rest_set( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $changes = self::validate_changes( $request->get_param( 'changes' ) ); if ( $changes instanceof \WP_Error ) { return $changes; } $results = array(); $tally = array( 'changed' => 0, 'skipped' => 0, 'not_found' => 0, 'forbidden' => 0, 'failed' => 0, ); foreach ( $changes as $change ) { $result = $this->apply_change( $change['id'], $change['from'], $change['to'] ); $results[] = $result; $tally[ $result['result'] ] = ( $tally[ $result['result'] ] ?? 0 ) + 1; } $this->logger->info( sprintf( 'Post discussion settings: %d changed, %d skipped, %d not found, %d forbidden, %d failed.', $tally['changed'], $tally['skipped'], $tally['not_found'], $tally['forbidden'], $tally['failed'] ) ); return new \WP_REST_Response( array( 'results' => $results, 'counts' => $tally, ), 200 ); } /** * The request's changes, validated whole: 1–200 of `{id, from, to}` with a * positive id, both settings named on each side, a real difference, and no * id twice. * * @param mixed $raw The `changes` parameter. * @return array|\WP_Error */ public static function validate_changes( mixed $raw ): array|\WP_Error { if ( ! is_array( $raw ) || array() === $raw || count( $raw ) > self::MAX_ITEMS ) { return self::bad_request( sprintf( 'changes must list 1 to %d posts.', self::MAX_ITEMS ) ); } $out = array(); $seen = array(); foreach ( $raw as $entry ) { $id = is_array( $entry ) && is_numeric( $entry['id'] ?? null ) ? (int) $entry['id'] : 0; $from = is_array( $entry ) ? self::settings( $entry['from'] ?? null ) : null; $to = is_array( $entry ) ? self::settings( $entry['to'] ?? null ) : null; if ( $id <= 0 || null === $from || null === $to || $from === $to ) { return self::bad_request( 'Each change needs a post id and two different discussion settings.' ); } if ( isset( $seen[ $id ] ) ) { return self::bad_request( 'A post may appear only once.' ); } $seen[ $id ] = true; $out[] = array( 'id' => $id, 'from' => $from, 'to' => $to, ); } return $out; } /** * Change one post, if it is still where the caller saw it. * * @param int $id Post id. * @param array{comments: string, pings: string} $from The settings the caller expects. * @param array{comments: string, pings: string} $to The settings to give it. * @return array{id: int, result: string, comment_status: string|null, ping_status: string|null} */ private function apply_change( int $id, array $from, array $to ): array { $post = get_post( $id ); if ( ! $post instanceof \WP_Post ) { return self::result( $id, 'not_found', null ); } $current = self::current( $post ); // A post whose row is not the record (an order, {@see Post_Storage}) is not WingPilot's to change. if ( null !== Post_Storage::refusal( $post ) || ! current_user_can( 'edit_post', $id ) ) { return self::result( $id, 'forbidden', $current ); } if ( $current !== $from ) { return self::result( $id, 'skipped', $current ); } $updated = wp_update_post( array( 'ID' => $id, 'comment_status' => $to['comments'], 'ping_status' => $to['pings'], ), true ); if ( is_wp_error( $updated ) ) { $this->logger->error( 'Failed to write discussion settings for post ' . $id ); return self::result( $id, 'failed', $current ); } clean_post_cache( $id ); $after = get_post( $id ); $now = $after instanceof \WP_Post ? self::current( $after ) : null; return self::result( $id, $now === $to ? 'changed' : 'failed', $now ); } /** * What the backend may know about one post's discussion. * * @param \WP_Post $post The post. * @return array */ public static function describe( \WP_Post $post ): array { $current = self::current( $post ); return array( 'id' => (int) $post->ID, 'title' => (string) get_the_title( $post ), 'type' => (string) $post->post_type, 'status' => (string) $post->post_status, 'comment_status' => $current['comments'], 'ping_status' => $current['pings'], 'comment_count' => (int) $post->comment_count, // 1.5.25: by status, so a trash card can say what travels with the // post (wptech.group: 178 waiting comments went unmentioned). 'comment_counts' => self::comment_counts( (int) $post->ID ), ); } /** * One post's comments counted per status, as WordPress counts them. * * @param int $post_id The post. * @return array{approved: int, pending: int, spam: int, trash: int, post_trashed: int} */ public static function comment_counts( int $post_id ): array { $counts = (array) wp_count_comments( $post_id ); return array( 'approved' => (int) ( $counts['approved'] ?? 0 ), 'pending' => (int) ( $counts['moderated'] ?? 0 ), 'spam' => (int) ( $counts['spam'] ?? 0 ), 'trash' => (int) ( $counts['trash'] ?? 0 ), 'post_trashed' => (int) ( $counts['post-trashed'] ?? 0 ), ); } /** * A post's two settings, normalised to 'open' or 'closed' (WordPress * treats anything that is not 'open' as closed). * * @param \WP_Post $post The post. * @return array{comments: string, pings: string} */ private static function current( \WP_Post $post ): array { return array( 'comments' => 'open' === (string) $post->comment_status ? 'open' : 'closed', 'pings' => 'open' === (string) $post->ping_status ? 'open' : 'closed', ); } /** * `{comments, pings}` validated, or null. * * @param mixed $raw One side of a change. * @return array{comments: string, pings: string}|null */ private static function settings( mixed $raw ): ?array { if ( ! is_array( $raw ) ) { return null; } $comments = $raw['comments'] ?? null; $pings = $raw['pings'] ?? null; if ( ! in_array( $comments, self::VALUES, true ) || ! in_array( $pings, self::VALUES, true ) ) { return null; } return array( 'comments' => (string) $comments, 'pings' => (string) $pings, ); } /** * `ids` as a list of positive integers: null when malformed or over the * limit, an empty list when absent. * * @param mixed $raw Comma-separated ids, or an array of them. * @return array|null */ public static function parse_ids( mixed $raw ): ?array { if ( null === $raw || '' === $raw ) { return array(); } $parts = is_array( $raw ) ? $raw : explode( ',', (string) $raw ); $ids = array(); foreach ( $parts as $part ) { if ( ! is_numeric( $part ) || (int) $part <= 0 ) { return null; } $ids[ (int) $part ] = (int) $part; } return count( $ids ) > self::MAX_ITEMS ? null : array_values( $ids ); } /** * @param array{comments: string, pings: string}|null $settings * @return array{id: int, result: string, comment_status: string|null, ping_status: string|null} */ private static function result( int $id, string $result, ?array $settings ): array { return array( 'id' => $id, 'result' => $result, 'comment_status' => $settings['comments'] ?? null, 'ping_status' => $settings['pings'] ?? null, ); } private static function bad_request( string $message ): \WP_Error { return new \WP_Error( 'wppilot_bad_request', $message, array( 'status' => 400 ) ); } }