add_action( 'rest_api_init', $this, 'register_routes' ); } public function register_routes(): void { register_rest_route( 'academiccrew/v1', '/publications', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_publications' ], 'permission_callback' => '__return_true', 'args' => $this->collection_params(), ], ] ); register_rest_route( 'academiccrew/v1', '/publications/filters', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_filters' ], 'permission_callback' => '__return_true', ], ] ); } // --------------------------------------------------------------- // GET /publications // --------------------------------------------------------------- public function get_publications( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; $pub_table = esc_sql( $wpdb->prefix . 'academiccrew_publications' ); $mp_table = esc_sql( $wpdb->prefix . 'academiccrew_member_publications' ); $search = sanitize_text_field( $request->get_param( 'search' ) ?? '' ); $year_from = (int) ( $request->get_param( 'year_from' ) ?? 0 ); $year_to = (int) ( $request->get_param( 'year_to' ) ?? 0 ); $type = sanitize_key( $request->get_param( 'type' ) ?? '' ); $member_id = (int) ( $request->get_param( 'member_id' ) ?? 0 ); $sort = $request->get_param( 'sort' ) ?? 'year_desc'; $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); $per_page = min( 50, max( 1, (int) ( $request->get_param( 'per_page' ) ?? 20 ) ) ); $offset = ( $page - 1 ) * $per_page; // --- Build WHERE conditions --- $conditions = [ '1=1' ]; $filter_args = []; if ( $search !== '' ) { $like = '%' . $wpdb->esc_like( $search ) . '%'; if ( version_compare( (string) get_option( 'academiccrew_db_version', '1.0' ), '1.5', '>=' ) ) { $conditions[] = '(p.title LIKE %s OR p.venue LIKE %s OR p.authors_json LIKE %s OR p.keywords LIKE %s)'; $filter_args[] = $like; $filter_args[] = $like; $filter_args[] = $like; $filter_args[] = $like; } else { $conditions[] = '(p.title LIKE %s OR p.venue LIKE %s OR p.authors_json LIKE %s)'; $filter_args[] = $like; $filter_args[] = $like; $filter_args[] = $like; } } if ( $year_from > 0 ) { $conditions[] = 'p.year >= %d'; $filter_args[] = $year_from; } if ( $year_to > 0 ) { $conditions[] = 'p.year <= %d'; $filter_args[] = $year_to; } if ( $type !== '' ) { $conditions[] = 'p.pub_type = %s'; $filter_args[] = $type; } if ( $member_id > 0 ) { // EXISTS subquery keeps the LEFT JOIN below unaffected (it fetches ALL member links) $conditions[] = "EXISTS ( SELECT 1 FROM {$mp_table} _fmp WHERE _fmp.publication_id = p.id AND _fmp.member_id = %d )"; $filter_args[] = $member_id; } $featured = (int) ( $request->get_param( 'featured' ) ?? 0 ); $tag_id = (int) ( $request->get_param( 'tag_id' ) ?? 0 ); if ( $featured > 0 ) { $conditions[] = 'p.is_featured = 1'; } if ( $tag_id > 0 ) { $tag_table = esc_sql( $wpdb->prefix . 'academiccrew_pub_tag_relations' ); $conditions[] = "EXISTS (SELECT 1 FROM {$tag_table} _tr WHERE _tr.publication_id = p.id AND _tr.tag_id = %d)"; $filter_args[] = $tag_id; } $where = esc_sql( implode( ' AND ', $conditions ) ); // --- Count total --- $count_sql = "SELECT COUNT(DISTINCT p.id) FROM {$pub_table} p WHERE {$where}"; $total = empty( $filter_args ) ? (int) $wpdb->get_var( $count_sql ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared : (int) $wpdb->get_var( $wpdb->prepare( $count_sql, ...$filter_args ) ); // --- ORDER BY --- $order = esc_sql( match ( $sort ) { 'year_asc' => 'p.year ASC, p.id ASC', 'citations' => 'COALESCE(p.citations, 0) DESC, p.year DESC, p.id DESC', 'citations_asc' => 'COALESCE(p.citations, 0) ASC, p.year DESC, p.id DESC', 'title' => 'p.title ASC', 'title_desc' => 'p.title DESC', default => 'p.year DESC, p.id DESC', } ); // --- Fetch page rows + grouped member IDs --- // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $data_sql = " SELECT p.*, GROUP_CONCAT(mp.member_id ORDER BY mp.member_id SEPARATOR ',') AS member_ids FROM {$pub_table} p LEFT JOIN {$mp_table} mp ON p.id = mp.publication_id WHERE {$where} GROUP BY p.id ORDER BY {$order} LIMIT %d OFFSET %d "; // phpcs:enable $data_args = array_merge( $filter_args, [ $per_page, $offset ] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $rows = $wpdb->get_results( $wpdb->prepare( $data_sql, ...$data_args ) ); $tag_map = []; if ( $rows ) { $pub_ids = array_map( fn( $r ) => (int) $r->id, $rows ); $placeholders = implode( ',', array_fill( 0, count( $pub_ids ), '%d' ) ); $tag_rows_raw = $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 ) ) ?: []; foreach ( $tag_rows_raw as $tr ) { $tag_map[ (int) $tr->publication_id ][] = [ 'name' => $tr->name, 'color' => $tr->color ]; } } return new \WP_REST_Response( [ 'items' => array_map( fn( $r ) => $this->format_row( $r, $tag_map ), $rows ?: [] ), 'total' => $total, 'pages' => $total > 0 ? (int) ceil( $total / $per_page ) : 0, ] ); } // --------------------------------------------------------------- // GET /publications/filters // --------------------------------------------------------------- public function get_filters(): \WP_REST_Response { global $wpdb; $pub_table = esc_sql( $wpdb->prefix . 'academiccrew_publications' ); $mp_table = esc_sql( $wpdb->prefix . 'academiccrew_member_publications' ); $years = $wpdb->get_col( "SELECT DISTINCT year FROM {$pub_table} WHERE year IS NOT NULL AND year > 0 AND year < 9000 ORDER BY year DESC" ); $type_rows = $wpdb->get_results( "SELECT pub_type AS `key`, COUNT(*) AS count FROM {$pub_table} WHERE pub_type IS NOT NULL AND pub_type != '' GROUP BY pub_type ORDER BY pub_type ASC" ); $member_rows = $wpdb->get_results( "SELECT DISTINCT p.ID, p.post_title, pm.meta_value AS linked_page_id FROM {$wpdb->posts} p INNER JOIN {$mp_table} mp ON p.ID = mp.member_id LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_academiccrew_linked_page_id' WHERE p.post_type = 'academiccrew_member' AND p.post_status = 'publish' ORDER BY p.post_title ASC" ); $has_featured = (bool) $wpdb->get_var( "SELECT COUNT(*) FROM {$pub_table} WHERE is_featured = 1" ); $tag_rows_f = $wpdb->get_results( "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 ORDER BY t.name ASC" ) ?: []; $type_labels = $this->type_labels(); return new \WP_REST_Response( [ 'years' => array_map( 'intval', $years ), 'types' => array_map( fn( $t ) => [ 'key' => $t->key, 'label' => $type_labels[ $t->key ] ?? ucwords( str_replace( '-', ' ', $t->key ) ), 'count' => (int) $t->count, ], $type_rows ), 'members' => array_map( fn( $r ) => [ 'id' => (int) $r->ID, 'name' => html_entity_decode( $r->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 'page_url' => $r->linked_page_id ? (string) get_permalink( (int) $r->linked_page_id ) : '', ], $member_rows ), 'hasFeatured' => $has_featured, 'tags' => array_map( fn( $t ) => [ 'id' => (int) $t->id, 'name' => $t->name, 'color' => $t->color ], $tag_rows_f ), ] ); } // --------------------------------------------------------------- // Helpers // --------------------------------------------------------------- private function format_row( object $row, array $tag_map = [] ): array { $authors_raw = json_decode( $row->authors_json ?? '[]', true ) ?: []; $member_ids = $row->member_ids ? array_unique( array_map( 'intval', explode( ',', $row->member_ids ) ) ) : []; $members = []; foreach ( $member_ids as $mid ) { $post = get_post( $mid ); if ( ! $post || 'publish' !== $post->post_status ) { continue; } $page_id = (int) get_post_meta( $mid, '_academiccrew_linked_page_id', true ); $members[] = [ 'id' => $mid, 'name' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 'page_url' => $page_id ? (string) get_permalink( $page_id ) : '', ]; } // name (lowercase) → page_url, for cross-referencing authors with lab members $member_by_name = []; foreach ( $members as $m ) { if ( $m['page_url'] ) { $member_by_name[ strtolower( trim( $m['name'] ) ) ] = $m['page_url']; } } $structured_authors = array_values( array_filter( array_map( fn( $raw ) => $this->parse_author( $raw, $member_by_name ), $authors_raw ), fn( $a ) => $a['firstName'] !== '' || $a['lastName'] !== '' ) ); $type_labels = $this->type_labels(); $pub_type = $row->pub_type ?? 'other'; return [ 'id' => (int) $row->id, 'doi' => $row->doi ?? null, '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' => $pub_type, 'type_label' => $type_labels[ $pub_type ] ?? ucwords( str_replace( '-', ' ', $pub_type ) ), 'authors' => $structured_authors, 'citations' => isset( $row->citations ) && $row->citations > 0 ? (int) $row->citations : null, 'url' => $row->url ?? null, 'pdf_url' => $row->pdf_url ?? null, 'members' => $members, 'tags' => $tag_map[(int) $row->id] ?? [], 'keywords' => ! empty( $row->keywords ) ? ( json_decode( $row->keywords, true ) ?: [] ) : [], ]; } /** * Parse one entry from authors_json into a structured author object. * Handles three input shapes: * - flat string: "Avignone, Andrea" or "Andrea Avignone" * - legacy array: ['name' => 'Andrea Avignone'] * - new array: ['firstName' => 'Andrea', 'lastName' => 'Avignone', ...] * * Cross-references $member_by_name (name → page_url) to populate pageUrl * for lab members even when authors_json predates structured storage. * * @param mixed $raw One decoded authors_json entry. * @param array $member_by_name Map of lowercased member name → page URL. */ private function parse_author( mixed $raw, array $member_by_name ): array { if ( is_array( $raw ) ) { if ( isset( $raw['lastName'] ) ) { // Already structured (new format) $first = (string) ( $raw['firstName'] ?? '' ); $last = (string) ( $raw['lastName'] ?? '' ); $page_url = (string) ( $raw['pageUrl'] ?? '' ); $orcid = (string) ( $raw['orcid'] ?? '' ); } else { // Legacy {'name': '...'} — recurse as string $delegated = $this->parse_author( (string) ( $raw['name'] ?? '' ), $member_by_name ); if ( isset( $raw['orcid'] ) ) { $delegated['orcid'] = (string) $raw['orcid']; } return $delegated; } } else { $str = trim( (string) $raw ); 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 ); } $page_url = ''; $orcid = ''; } // Cross-reference with linked lab members when pageUrl not yet stored if ( ! $page_url ) { foreach ( [ strtolower( trim( $first . ' ' . $last ) ), strtolower( trim( $last . ' ' . $first ) ), ] as $key ) { if ( isset( $member_by_name[ $key ] ) ) { $page_url = $member_by_name[ $key ]; break; } } } $result = [ 'firstName' => $first, 'lastName' => $last ]; if ( $page_url ) $result['pageUrl'] = $page_url; if ( $orcid ) $result['orcid'] = $orcid; return $result; } private function type_labels(): array { return [ 'journal-article' => __( 'Journal', 'academiccrew' ), 'conference-paper' => __( 'Conference', 'academiccrew' ), 'book-chapter' => __( 'Book Chapter', 'academiccrew' ), 'preprint' => __( 'Preprint', 'academiccrew' ), 'book' => __( 'Book', 'academiccrew' ), 'thesis' => __( 'Thesis', 'academiccrew' ), 'report' => __( 'Report', 'academiccrew' ), 'other' => __( 'Other', 'academiccrew' ), ]; } private function collection_params(): array { return [ 'page' => [ 'type' => 'integer', 'default' => 1, 'minimum' => 1 ], 'per_page' => [ 'type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 50 ], 'search' => [ 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field' ], 'year_from' => [ 'type' => 'integer', 'default' => 0 ], 'year_to' => [ 'type' => 'integer', 'default' => 0 ], 'type' => [ 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_key' ], 'member_id' => [ 'type' => 'integer', 'default' => 0 ], 'featured' => [ 'type' => 'integer', 'default' => 0 ], 'tag_id' => [ 'type' => 'integer', 'default' => 0 ], 'sort' => [ 'type' => 'string', 'default' => 'year_desc', 'enum' => [ 'year_desc', 'year_asc', 'citations', 'citations_asc', 'title', 'title_desc' ], ], ]; } }