add_action( 'rest_api_init', $this, 'register_routes' ); } public function register_routes(): void { register_rest_route( 'academiccrew/v1', '/members', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_members' ], 'permission_callback' => '__return_true', 'args' => $this->args_schema(), ] ); register_rest_route( 'academiccrew/v1', '/members/filters', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_filter_options' ], 'permission_callback' => '__return_true', ] ); } public function get_members( \WP_REST_Request $req ): \WP_REST_Response { $meta_query = [ 'relation' => 'AND' ]; if ( ! empty( $req['role'] ) ) { $meta_query[] = [ 'key' => '_acc_member_role', 'value' => sanitize_key( $req['role'] ), 'compare' => '=', ]; } if ( ! empty( $req['area'] ) ) { $meta_query[] = [ 'key' => '_acc_research_areas', 'value' => sanitize_text_field( $req['area'] ), 'compare' => 'LIKE', ]; } $search = sanitize_text_field( $req['search'] ?? '' ); $query_args = [ 'post_type' => 'academiccrew_member', 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', 'meta_query' => $meta_query, ]; if ( $search ) { $query_args['s'] = $search; } $query = new \WP_Query( $query_args ); $members = array_map( [ $this, 'prepare_member' ], $query->posts ); $role_order = array_keys( RoleRegistry::all() ); usort( $members, function ( $a, $b ) use ( $role_order ) { // 1. Role order. $ai = array_search( $a['role'], $role_order, true ); $bi = array_search( $b['role'], $role_order, true ); $ai = $ai === false ? 99 : (int) $ai; $bi = $bi === false ? 99 : (int) $bi; if ( $ai !== $bi ) return $ai - $bi; // 2. Rank within role (0 = unset → last). $ar = $a['rank'] > 0 ? $a['rank'] : PHP_INT_MAX; $br = $b['rank'] > 0 ? $b['rank'] : PHP_INT_MAX; if ( $ar !== $br ) return $ar - $br; // 3. Last name alphabetical. $cmp = strcmp( $a['last_name'], $b['last_name'] ); if ( $cmp !== 0 ) return $cmp; // 4. First name alphabetical. return strcmp( $a['first_name'], $b['first_name'] ); } ); $grouped = []; foreach ( $members as $m ) { $grouped[ $m['role'] ][] = $m; } return new \WP_REST_Response( [ 'members' => $members, 'grouped' => $grouped, 'total' => count( $members ), ], 200 ); } private function prepare_member( \WP_Post $post ): array { $get = fn( string $key ) => get_post_meta( $post->ID, $key, true ); $role = $get( '_acc_member_role' ) ?: 'collaborator'; $research_raw = $get( '_acc_research_areas' ); $areas = is_array( $research_raw ) ? $research_raw : ( $research_raw ? array_map( 'trim', explode( ',', $research_raw ) ) : [] ); $social_raw = $get( '_acc_social_links' ); $social = is_array( $social_raw ) ? $social_raw : []; $orcid_id = $get( '_acc_orcid_id' ); $linked_page_id = (int) $get( '_academiccrew_linked_page_id' ); global $wpdb; $pub_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}academiccrew_member_publications WHERE member_id = %d", $post->ID ) ); $first_name = (string) ( $get( '_acc_first_name' ) ?: '' ); $last_name = (string) ( $get( '_acc_last_name' ) ?: '' ); // Derive first/last name from title when meta is not set. if ( ! $first_name && ! $last_name ) { $parts = array_values( array_filter( preg_split( '/\s+/', trim( $post->post_title ) ) ) ); $last_name = count( $parts ) > 1 ? array_pop( $parts ) : ( $parts[0] ?? '' ); $first_name = implode( ' ', $parts ); } return [ 'id' => $post->ID, 'name' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 'slug' => $post->post_name, 'first_name' => $first_name, 'last_name' => $last_name, 'rank' => (int) ( $get( '_acc_role_rank' ) ?: 0 ), 'role' => $role, 'role_label' => RoleRegistry::label( $role ), 'role_colors' => RoleRegistry::colors()[ $role ] ?? [], 'position' => $get( '_acc_position' ) ?: RoleRegistry::label( $role ), 'affiliation' => $get( '_acc_affiliation' ) ?: '', 'bio' => $get( '_acc_bio' ) ?: '', 'email' => $get( '_acc_email' ) ?: '', 'website' => $get( '_acc_personal_website' ) ?: '', 'avatar_url' => $get( '_acc_avatar_url' ) ?: ( get_the_post_thumbnail_url( $post->ID, 'medium' ) ?: null ), 'initials' => $this->initials( $post->post_title ), 'orcid_id' => $orcid_id ?: null, 'orcid_url' => $orcid_id ? "https://orcid.org/{$orcid_id}" : null, 'research_areas' => $areas, 'social' => $social, 'pub_count' => $pub_count, 'citation_count' => (int) ( $get( '_acc_citation_count' ) ?: 0 ), 'citation_url' => MemberStats::citation_url( $post->ID ) ?: null, 'page_url' => $linked_page_id ? get_permalink( $linked_page_id ) : get_permalink( $post->ID ), ]; } public function get_filter_options(): \WP_REST_Response { $all_areas = []; $member_ids = get_posts( [ 'post_type' => 'academiccrew_member', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', ] ); foreach ( $member_ids as $id ) { $raw = get_post_meta( $id, '_acc_research_areas', true ); $areas = is_array( $raw ) ? $raw : array_map( 'trim', explode( ',', $raw ?: '' ) ); foreach ( array_filter( $areas ) as $area ) { $all_areas[ $area ] = ( $all_areas[ $area ] ?? 0 ) + 1; } } arsort( $all_areas ); return new \WP_REST_Response( [ 'roles' => array_map( fn( $key, $label ) => [ 'key' => $key, 'label' => $label ], array_keys( RoleRegistry::all() ), array_values( RoleRegistry::all() ) ), 'areas' => array_map( fn( $area, $count ) => [ 'label' => $area, 'count' => $count ], array_keys( $all_areas ), array_values( $all_areas ) ), ], 200 ); } private function initials( string $name ): string { $parts = array_values( array_filter( preg_split( '/\s+/', trim( $name ) ) ) ); return strtoupper( implode( '', array_map( fn( $w ) => mb_substr( $w, 0, 1 ), array_slice( $parts, 0, 2 ) ) ) ); } private function args_schema(): array { return [ 'role' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_key' ], 'area' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], 'search' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], ]; } }