add_action( 'rest_api_init', $this, 'register_routes' ); } public function register_routes(): void { register_rest_route( 'academiccrew/v1', '/members/(?P\d+)/publications', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_publications' ], 'permission_callback' => '__return_true', 'args' => $this->args_schema(), ] ); register_rest_route( 'academiccrew/v1', '/members/(?P\d+)/publications/filters', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_filter_options' ], 'permission_callback' => '__return_true', ] ); } public function get_publications( \WP_REST_Request $req ): \WP_REST_Response { global $wpdb; $member_id = (int) $req['id']; if ( ! get_post( $member_id ) ) { return new \WP_REST_Response( [ 'error' => 'Member not found' ], 404 ); } $per_page = min( 50, max( 5, (int) ( $req['per_page'] ?? 10 ) ) ); $page = max( 1, (int) ( $req['page'] ?? 1 ) ); $offset = ( $page - 1 ) * $per_page; $where = [ 'mp.member_id = %d' ]; $params = [ $member_id ]; if ( ! empty( $req['type'] ) ) { $where[] = 'p.pub_type = %s'; $params[] = sanitize_text_field( $req['type'] ); } if ( ! empty( $req['year'] ) ) { if ( str_contains( (string) $req['year'], '-' ) ) { [ $from, $to ] = explode( '-', $req['year'], 2 ); $where[] = 'p.year BETWEEN %d AND %d'; $params[] = (int) $from; $params[] = (int) $to; } else { $where[] = 'p.year = %d'; $params[] = (int) $req['year']; } } if ( ! empty( $req['search'] ) ) { $like = '%' . $wpdb->esc_like( sanitize_text_field( $req['search'] ) ) . '%'; if ( version_compare( (string) get_option( 'academiccrew_db_version', '1.0' ), '1.5', '>=' ) ) { $where[] = '(p.title LIKE %s OR p.venue LIKE %s OR p.keywords LIKE %s)'; $params[] = $like; $params[] = $like; $params[] = $like; } else { $where[] = '(p.title LIKE %s OR p.venue LIKE %s)'; $params[] = $like; $params[] = $like; } } $featured = (int) ( $req['featured'] ?? 0 ); $tag_id = (int) ( $req['tag_id'] ?? 0 ); if ( $featured > 0 ) { $where[] = 'p.is_featured = 1'; } if ( $tag_id > 0 ) { $tag_table = esc_sql( $wpdb->prefix . 'academiccrew_pub_tag_relations' ); $where[] = "EXISTS (SELECT 1 FROM {$tag_table} _tr WHERE _tr.publication_id = p.id AND _tr.tag_id = %d)"; $params[] = $tag_id; } $where_sql = esc_sql( 'WHERE ' . implode( ' AND ', $where ) ); $order_sql = esc_sql( match ( sanitize_key( $req['sort'] ?? 'year_desc' ) ) { 'year_asc' => 'p.year ASC, p.title ASC', 'citations' => 'COALESCE(p.citations, 0) DESC, p.year DESC', 'citations_asc' => 'COALESCE(p.citations, 0) ASC, p.year DESC', 'title' => 'p.title ASC', 'title_desc' => 'p.title DESC', default => 'p.year DESC, p.citations DESC', } ); $pub_table = esc_sql( $wpdb->prefix . 'academiccrew_publications' ); $rel_table = esc_sql( $wpdb->prefix . 'academiccrew_member_publications' ); $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$pub_table} p INNER JOIN {$rel_table} mp ON p.id = mp.publication_id {$where_sql}", ...$params ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT p.*, mp.is_corresponding FROM {$pub_table} p INNER JOIN {$rel_table} mp ON p.id = mp.publication_id {$where_sql} ORDER BY {$order_sql} LIMIT %d OFFSET %d", ...[ ...$params, $per_page, $offset ] ), ARRAY_A ); $publications = array_map( [ $this, 'format_publication' ], $rows ?: [] ); if ( $publications ) { $pub_ids = array_column( $rows ?: [], 'id' ); $placeholders = implode( ',', array_fill( 0, count( $pub_ids ), '%d' ) ); $tag_rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore "SELECT r.publication_id, t.name, t.color FROM {$wpdb->prefix}academiccrew_pub_tag_relations r INNER JOIN {$wpdb->prefix}academiccrew_pub_tags t ON t.id = r.tag_id WHERE r.publication_id IN ({$placeholders}) ORDER BY t.name ASC", // phpcs:ignore ...$pub_ids ), ARRAY_A ) ?: []; $tag_map = []; foreach ( $tag_rows as $tr ) { $tag_map[ (int) $tr['publication_id'] ][] = [ 'name' => $tr['name'], 'color' => $tr['color'] ]; } foreach ( $publications as &$pub ) { $pub['tags'] = $tag_map[ $pub['id'] ] ?? []; } unset( $pub ); } return new \WP_REST_Response( [ 'publications' => $publications, 'total' => $total, 'total_pages' => (int) ceil( $total / $per_page ), 'page' => $page, 'per_page' => $per_page, ], 200 ); } public function get_filter_options( \WP_REST_Request $req ): \WP_REST_Response { global $wpdb; $member_id = (int) $req['id']; $pub_table = esc_sql( $wpdb->prefix . 'academiccrew_publications' ); $rel_table = esc_sql( $wpdb->prefix . 'academiccrew_member_publications' ); $types = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT p.pub_type FROM {$pub_table} p INNER JOIN {$rel_table} mp ON p.id = mp.publication_id WHERE mp.member_id = %d AND p.pub_type IS NOT NULL ORDER BY p.pub_type", $member_id ) ); $years = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT p.year FROM {$pub_table} p INNER JOIN {$rel_table} mp ON p.id = mp.publication_id WHERE mp.member_id = %d AND p.year IS NOT NULL ORDER BY p.year DESC", $member_id ) ); $has_featured = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$pub_table} p INNER JOIN {$rel_table} mp ON p.id = mp.publication_id WHERE mp.member_id = %d AND p.is_featured = 1", $member_id ) ); $member_tags = $wpdb->get_results( $wpdb->prepare( "SELECT DISTINCT t.id, t.name, t.color FROM {$wpdb->prefix}academiccrew_pub_tags t INNER JOIN {$wpdb->prefix}academiccrew_pub_tag_relations r ON r.tag_id = t.id INNER JOIN {$rel_table} mp ON mp.publication_id = r.publication_id WHERE mp.member_id = %d AND EXISTS ( SELECT 1 FROM {$pub_table} p2 WHERE p2.id = r.publication_id AND p2.is_featured = 1 ) ORDER BY t.name ASC", $member_id ) ) ?: []; return new \WP_REST_Response( [ 'types' => $types, 'years' => array_map( 'intval', $years ), 'hasFeatured' => $has_featured, 'tags' => array_map( fn( $t ) => [ 'id' => (int) $t->id, 'name' => $t->name, 'color' => $t->color ], $member_tags ), ], 200 ); } private function parse_author_simple( mixed $raw ): array { $str = is_array( $raw ) ? ( isset( $raw['lastName'] ) ? null : (string) ( $raw['name'] ?? '' ) ) : trim( (string) $raw ); if ( $str === null ) { // Already structured {firstName, lastName} return [ 'firstName' => (string) ( $raw['firstName'] ?? '' ), 'lastName' => (string) ( $raw['lastName'] ?? '' ), ]; } if ( $str === '' ) return [ 'firstName' => '', 'lastName' => '' ]; if ( str_contains( $str, ',' ) ) { [ $last, $first ] = array_map( 'trim', explode( ',', $str, 2 ) ); } else { $parts = preg_split( '/\s+/', $str, -1, PREG_SPLIT_NO_EMPTY ); $last = $parts ? (string) array_pop( $parts ) : ''; $first = implode( ' ', $parts ); } return [ 'firstName' => $first, 'lastName' => $last ]; } private function format_publication( array $row ): array { $raw = json_decode( $row['authors_json'] ?? '[]', true ) ?: []; $authors = array_values( array_filter( array_map( [ $this, 'parse_author_simple' ], $raw ), fn( $a ) => $a['firstName'] !== '' || $a['lastName'] !== '' ) ); $doi = $row['doi']; return [ 'id' => (int) $row['id'], 'title' => html_entity_decode( (string) $row['title'], ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 'year' => $row['year'] ? (int) $row['year'] : null, 'venue' => isset( $row['venue'] ) ? html_entity_decode( $row['venue'], ENT_QUOTES | ENT_HTML5, 'UTF-8' ) : null, 'pub_type' => $row['pub_type'], 'abstract' => $row['abstract'], 'authors' => $authors, 'doi' => $doi, 'doi_url' => $doi ? "https://doi.org/{$doi}" : null, 'pdf_url' => $row['pdf_url'], 'url' => $row['url'], 'citations' => $row['citations'] ? (int) $row['citations'] : null, 'source' => $row['source_api'], 'is_corresponding' => (bool) $row['is_corresponding'], 'keywords' => ! empty( $row['keywords'] ) ? ( json_decode( $row['keywords'], true ) ?: [] ) : [], ]; } private function args_schema(): array { return [ 'type' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], 'year' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], 'search' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], 'sort' => [ 'type' => 'string', 'enum' => [ 'year_desc', 'year_asc', 'citations', 'citations_asc', 'title', 'title_desc' ], 'default' => 'year_desc' ], 'per_page' => [ 'type' => 'integer', 'default' => 10, 'minimum' => 5, 'maximum' => 50 ], 'page' => [ 'type' => 'integer', 'default' => 1, 'minimum' => 1 ], 'featured' => [ 'type' => 'integer', 'default' => 0 ], 'tag_id' => [ 'type' => 'integer', 'default' => 0 ], ]; } }