calc = new AcademicYearCalculator(); } public function register( Loader $loader ): void { $loader->add_action( 'rest_api_init', $this, 'register_routes' ); } public function register_routes(): void { register_rest_route( 'academiccrew/v1', '/courses', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_courses' ], 'permission_callback' => '__return_true', 'args' => $this->get_args_schema(), ] ); register_rest_route( 'academiccrew/v1', '/courses/filters', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_filter_options' ], 'permission_callback' => '__return_true', ] ); } public function get_courses( \WP_REST_Request $request ): \WP_REST_Response { $params = $request->get_params(); $meta_query = [ 'relation' => 'AND' ]; if ( ! empty( $params['period'] ) && (int) $params['period'] >= 1 && (int) $params['period'] <= PeriodCadence::period_count() ) { $meta_query[] = [ 'key' => '_acc_period', 'value' => (int) $params['period'], 'compare' => '=', 'type' => 'NUMERIC', ]; } if ( ! empty( $params['language'] ) && in_array( $params['language'], [ 'IT', 'EN' ] ) ) { $meta_query[] = [ 'key' => '_acc_language', 'value' => sanitize_text_field( $params['language'] ), 'compare' => '=', ]; } if ( ! empty( $params['academic_year'] ) ) { $meta_query[] = [ 'key' => '_acc_academic_year', 'value' => (int) $params['academic_year'], 'compare' => '=', 'type' => 'NUMERIC', ]; } $credits_min = isset( $params['credits_min'] ) && $params['credits_min'] !== '' ? (int) $params['credits_min'] : null; $credits_max = isset( $params['credits_max'] ) && $params['credits_max'] !== '' ? (int) $params['credits_max'] : null; if ( $credits_min && $credits_max ) { $meta_query[] = [ 'key' => '_acc_credits', 'value' => [ $credits_min, $credits_max ], 'compare' => 'BETWEEN', 'type' => 'NUMERIC', ]; } elseif ( $credits_min ) { $meta_query[] = [ 'key' => '_acc_credits', 'value' => $credits_min, 'compare' => '>=', 'type' => 'NUMERIC', ]; } elseif ( $credits_max ) { $meta_query[] = [ 'key' => '_acc_credits', 'value' => $credits_max, 'compare' => '<=', 'type' => 'NUMERIC', ]; } $degree_programs = array_values( array_filter( array_map( 'sanitize_text_field', (array) ( $params['degree_program'] ?? [] ) ) ) ); if ( ! empty( $degree_programs ) ) { $meta_query[] = [ 'key' => '_acc_degree_program', 'value' => count( $degree_programs ) === 1 ? $degree_programs[0] : $degree_programs, 'compare' => count( $degree_programs ) === 1 ? '=' : 'IN', ]; } $instructor_ids = array_values( array_filter( array_map( 'intval', (array) ( $params['instructor_id'] ?? [] ) ) ) ); if ( ! empty( $instructor_ids ) ) { if ( count( $instructor_ids ) === 1 ) { $meta_query[] = [ 'key' => '_acc_instructors', 'value' => '"member_id";i:' . $instructor_ids[0], 'compare' => 'LIKE', ]; } else { $sub = [ 'relation' => 'OR' ]; foreach ( $instructor_ids as $mid ) { $sub[] = [ 'key' => '_acc_instructors', 'value' => '"member_id";i:' . $mid, 'compare' => 'LIKE', ]; } $meta_query[] = $sub; } } $search = sanitize_text_field( $params['search'] ?? '' ); // The grid is ALWAYS academic-year-first (newest on top); the chosen key // only breaks ties. Credit sorts are the sole exception. Year ordering is // applied in add_year_sort_clauses() via a LEFT JOIN so courses missing // the meta are not dropped. $sort_key = sanitize_key( $params['sort'] ?? 'year_desc' ); $credit_sort = [ 'cfu_desc' => [ 'orderby' => 'meta_value_num', 'meta_key' => '_acc_credits', 'order' => 'DESC' ], 'cfu_asc' => [ 'orderby' => 'meta_value_num', 'meta_key' => '_acc_credits', 'order' => 'ASC' ], ]; $sort_params = $credit_sort[ $sort_key ] ?? []; $per_page = min( 48, max( 6, (int) ( $params['per_page'] ?? 12 ) ) ); $page = max( 1, (int) ( $params['page'] ?? 1 ) ); $query_args = array_merge( [ 'post_type' => 'academiccrew_course', 'post_status' => 'publish', 'posts_per_page' => $per_page, 'paged' => $page, 'meta_query' => $meta_query, '_acc_sort' => $sort_key, ], $sort_params ); add_filter( 'posts_clauses', [ $this, 'add_year_sort_clauses' ], 20, 2 ); if ( $search ) { $query_args['_acc_search_term'] = $search; add_filter( 'posts_where', [ $this, 'add_code_search_where' ], 10, 2 ); add_filter( 'posts_join', [ $this, 'add_code_search_join' ], 10, 2 ); } $query = new \WP_Query( $query_args ); remove_filter( 'posts_clauses', [ $this, 'add_year_sort_clauses' ], 20 ); if ( $search ) { remove_filter( 'posts_where', [ $this, 'add_code_search_where' ] ); remove_filter( 'posts_join', [ $this, 'add_code_search_join' ] ); } $courses = []; foreach ( $query->posts as $post ) { $courses[] = $this->prepare_course_data( $post ); } return new \WP_REST_Response( [ 'courses' => $courses, 'total' => (int) $query->found_posts, 'total_pages' => (int) $query->max_num_pages, 'page' => $page, ], 200 ); } public function get_filter_options(): \WP_REST_Response { global $wpdb; $years = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery "SELECT DISTINCT pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '_acc_academic_year' AND p.post_type = 'academiccrew_course' AND p.post_status = 'publish' ORDER BY pm.meta_value DESC" ); $degrees = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery "SELECT DISTINCT pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '_acc_degree_program' AND pm.meta_value != '' AND p.post_type = 'academiccrew_course' AND p.post_status = 'publish' ORDER BY pm.meta_value ASC" ); $credits = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery "SELECT DISTINCT CAST(pm.meta_value AS UNSIGNED) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '_acc_credits' AND p.post_type = 'academiccrew_course' AND p.post_status = 'publish' ORDER BY CAST(pm.meta_value AS UNSIGNED) ASC" ); $member_ids = get_posts( [ 'post_type' => 'academiccrew_member', 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', 'fields' => 'ids', ] ); $instructors = array_map( fn( $id ) => [ 'id' => $id, 'name' => html_entity_decode( get_the_title( $id ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ), ], $member_ids ); return new \WP_REST_Response( [ 'years' => array_map( 'intval', $years ), 'degrees' => $degrees, 'credits' => array_map( 'intval', $credits ), 'instructors' => $instructors, ], 200 ); } private function prepare_course_data( \WP_Post $post ): array { $meta = get_post_meta( $post->ID ); $extract = fn( string $key ) => $meta[ $key ][0] ?? null; $code = $extract( '_acc_course_code' ) ?? ''; $period = (int) ( $extract( '_acc_period' ) ?? 1 ); $language = $extract( '_acc_language' ) ?? 'IT'; $year = (int) ( $extract( '_acc_academic_year' ) ?? $this->calc->calculate( $period ) ); $instructors_raw = maybe_unserialize( $extract( '_acc_instructors' ) ?? '' ); $instructors = []; if ( is_array( $instructors_raw ) ) { foreach ( $instructors_raw as $entry ) { $mid = (int) ( $entry['member_id'] ?? 0 ); if ( ! $mid ) continue; $page_id = get_post_meta( $mid, '_academiccrew_linked_page_id', true ); $instructors[] = [ 'id' => $mid, 'name' => html_entity_decode( get_the_title( $mid ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 'role' => $entry['role'] ?? 'teacher', 'page_url' => $page_id ? get_permalink( $page_id ) : null, 'avatar' => get_post_meta( $mid, '_acc_avatar_url', true ) ?: null, 'initials' => $this->get_initials( get_the_title( $mid ) ), ]; } } $linked_page_id = get_post_meta( $post->ID, '_academiccrew_linked_page_id', true ); $page_url = $linked_page_id ? get_permalink( $linked_page_id ) : get_permalink( $post->ID ); return [ 'id' => $post->ID, 'title' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 'code' => $code, 'credits' => (int) ( $extract( '_acc_credits' ) ?? 0 ), 'degree_program' => $extract( '_acc_degree_program' ) ?? '', 'language' => $language, 'period' => $period, 'academic_year' => $year, 'academic_year_formatted' => $this->calc->format( $year ), 'instructors' => $instructors, 'page_url' => $page_url, 'moodle_url' => $extract( '_acc_moodle_url' ) ?? null, 'thumbnail' => get_the_post_thumbnail_url( $post->ID, 'medium' ) ?: null, ]; } private function get_initials( string $name ): string { $parts = array_filter( explode( ' ', trim( $name ) ) ); if ( count( $parts ) >= 2 ) { return strtoupper( mb_substr( $parts[0], 0, 1 ) . mb_substr( end( $parts ), 0, 1 ) ); } return strtoupper( mb_substr( $name, 0, 2 ) ); } /** * Force the archive to sort by academic year first (newest on top), then — * for that year — put courses of the teaching period running right now * ahead of the other period, then by the chosen secondary key. Uses LEFT * JOINs so courses without the meta rows still appear (sorted last). * Skipped for the explicit credit sorts. * * @param array $clauses */ public function add_year_sort_clauses( array $clauses, \WP_Query $query ): array { $sort = (string) $query->get( '_acc_sort' ); if ( '' === $sort || in_array( $sort, [ 'cfu_desc', 'cfu_asc' ], true ) ) { return $clauses; } global $wpdb; $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} acc_ay_sort ON ( {$wpdb->posts}.ID = acc_ay_sort.post_id AND acc_ay_sort.meta_key = '_acc_academic_year' )"; $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} acc_period_sort ON ( {$wpdb->posts}.ID = acc_period_sort.post_id AND acc_period_sort.meta_key = '_acc_period' )"; $year_dir = 'year_asc' === $sort ? 'ASC' : 'DESC'; $current_ay = $this->calc->calculate( 1 ); $current_period = $this->calc->current_period(); if ( 'code' === $sort ) { $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} acc_code_sort ON ( {$wpdb->posts}.ID = acc_code_sort.post_id AND acc_code_sort.meta_key = '_acc_course_code' )"; $secondary = 'acc_code_sort.meta_value ASC'; } else { $secondary = "{$wpdb->posts}.post_title ASC"; } // Same year as now AND same period as now → 0 (sorts first); everything // else (a different year, or the other period within the current year) → 1. $current_period_first = $wpdb->prepare( "( CASE WHEN CAST(acc_ay_sort.meta_value AS UNSIGNED) = %d AND CAST(acc_period_sort.meta_value AS UNSIGNED) = %d THEN 0 ELSE 1 END )", $current_ay, $current_period ); $clauses['orderby'] = "CAST(acc_ay_sort.meta_value AS UNSIGNED) {$year_dir}, {$current_period_first}, {$secondary}"; $clauses['groupby'] = "{$wpdb->posts}.ID"; return $clauses; } public function add_code_search_where( string $where, \WP_Query $query ): string { global $wpdb; $term = $query->get( '_acc_search_term' ); if ( ! $term ) return $where; $like = '%' . $wpdb->esc_like( $term ) . '%'; $where .= $wpdb->prepare( " AND ({$wpdb->posts}.post_title LIKE %s OR acc_code_search.meta_value LIKE %s)", $like, $like ); return $where; } public function add_code_search_join( string $join, \WP_Query $query ): string { global $wpdb; if ( ! $query->get( '_acc_search_term' ) ) return $join; $join .= " LEFT JOIN {$wpdb->postmeta} acc_code_search ON ({$wpdb->posts}.ID = acc_code_search.post_id AND acc_code_search.meta_key = '_acc_course_code')"; return $join; } private function get_args_schema(): array { return [ 'period' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => PeriodCadence::period_count() ], 'language' => [ 'type' => 'string', 'enum' => [ 'IT', 'EN' ] ], 'academic_year' => [ 'type' => 'integer', 'minimum' => 2000 ], 'credits_min' => [ 'type' => 'integer', 'minimum' => 1 ], 'credits_max' => [ 'type' => 'integer', 'minimum' => 1 ], 'degree_program' => [ 'type' => [ 'string', 'array' ] ], 'instructor_id' => [ 'type' => [ 'integer', 'array' ] ], 'search' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], 'sort' => [ 'type' => 'string', 'enum' => [ 'code', 'name', 'cfu_desc', 'cfu_asc', 'year_desc', 'year_asc' ], 'default' => 'year_desc' ], 'per_page' => [ 'type' => 'integer', 'default' => 12, 'minimum' => 1, 'maximum' => 48 ], 'page' => [ 'type' => 'integer', 'default' => 1, 'minimum' => 1 ], ]; } }