$id > 0 ) ) ); sort( $course_ids ); $total = count( $course_ids ); $course_ids = array_slice( $course_ids, 0, self::MAX_COURSES ); $out = array( 'roles' => $roles, 'courses' => array(), 'rules' => array(), 'total' => $total, ); if ( array() === $course_ids ) { return $out; } $items = self::items( $course_ids ); $units = array(); foreach ( $items as $list ) { foreach ( $list as $item ) { $units[] = $item[1]; } } $titles = Services::posts()->titles( ObjectType::COURSE, $course_ids, $lang ); $lessons = array() === $units ? array() : Services::posts()->titles( ObjectType::UNIT, array_values( array_unique( $units ) ), $lang ); $cats = $cats ?? PortalData::matrix_categories( $course_ids, $lang ); $rank = array_flip( array_keys( $cats['categories'] ) ); $rows = array(); foreach ( $course_ids as $course_id ) { $path = $cats['paths'][ $course_id ] ?? array( 'top' => 0, 'sub' => 0, ); $list = array(); foreach ( $items[ $course_id ] ?? array() as $item ) { $list[] = array( $item[0], (string) ( $lessons[ $item[1] ]['title'] ?? '' ) ); } $rows[] = array( 'id' => $course_id, 'title' => (string) ( $titles[ $course_id ]['title'] ?? '' ), 'top' => $path['top'] > 0 ? (string) ( $cats['categories'][ $path['top'] ]['name'] ?? '' ) : '', 'sub' => $path['sub'] > 0 ? (string) ( $cats['categories'][ $path['sub'] ]['name'] ?? '' ) : '', 'lessons' => $list, 'sort' => array( $path['top'] > 0 ? ( $rank[ $path['top'] ] ?? PHP_INT_MAX ) : PHP_INT_MAX, $path['sub'] > 0 ? ( $rank[ $path['sub'] ] ?? PHP_INT_MAX ) : -1 ), ); } usort( $rows, static function ( array $a, array $b ): int { $by = $a['sort'] <=> $b['sort']; if ( 0 === $by ) { $by = strcasecmp( remove_accents( $a['title'] ), remove_accents( $b['title'] ) ); } return 0 === $by ? $a['id'] <=> $b['id'] : $by; } ); foreach ( $rows as $row ) { unset( $row['sort'] ); $out['courses'][] = $row; } $out['rules'] = self::rules( $template_id, $course_ids, array_keys( $roles ) ); return $out; } private static function items( array $course_ids ): array { $out = array(); $rows = Db::rows( 'SELECT i.id, i.course_id, i.unit_id FROM %i i JOIN %i u ON u.id = i.unit_id AND u.state = 1 LEFT JOIN %i s ON s.id = i.section_id WHERE i.course_id IN (' . implode( ',', array_fill( 0, count( $course_ids ), '%d' ) ) . ') AND i.state = 1 ORDER BY i.course_id, CASE WHEN i.section_id IS NULL THEN 0 ELSE 1 END, s.position, s.id, i.position, i.id', array_merge( array( Db::table( 'items' ), Db::table( 'units' ), Db::table( 'sections' ) ), $course_ids ) ); foreach ( $rows as $row ) { $out[ (int) $row['course_id'] ][] = array( (int) $row['id'], (int) $row['unit_id'] ); } return $out; } private static function rules( int $template_id, array $course_ids, array $role_ids ): array { if ( $template_id <= 0 || array() === $role_ids ) { return array(); } $courses = array_flip( $course_ids ); $roles = array_flip( array_map( 'intval', $role_ids ) ); $out = array(); foreach ( Db::rows( 'SELECT job_role_id, course_id, item_id FROM %i WHERE template_id = %d ORDER BY job_role_id, course_id, item_id', Db::table( 'access_rules' ), $template_id ) as $row ) { if ( isset( $courses[ (int) $row['course_id'] ], $roles[ (int) $row['job_role_id'] ] ) ) { $out[ (int) $row['job_role_id'] . ':' . (int) $row['course_id'] . ':' . (int) $row['item_id'] ] = true; } } return $out; } public static function per_page( int $courses ): int { $raw = isset( $_COOKIE[ self::COOKIE ] ) ? sanitize_key( wp_unslash( (string) $_COOKIE[ self::COOKIE ] ) ) : ''; $size = '' === $raw || ! ctype_digit( $raw ) ? self::DEFAULT_SIZE : (int) $raw; if ( ! in_array( $size, self::SIZES, true ) || ( 0 === $size && $courses > self::ALL_LIMIT ) ) { $size = self::DEFAULT_SIZE; } return $size; } public static function render( array $data, array $args ): string { Assets::enqueue_matrix(); $id = 'rooster-lms-matrix-' . ( ++self::$count ); $roles = $data['roles']; $rules = $data['rules']; $courses = $data['courses']; $n = count( $courses ); $size = self::per_page( $n ); $sizes = array_values( array_filter( self::SIZES, static fn( int $s ): bool => 0 !== $s || $n <= self::ALL_LIMIT ) ); $open = array(); $json = array(); foreach ( $courses as $course ) { $json[] = array( $course['id'], $course['title'], $course['top'], $course['sub'], $course['lessons'] ); if ( count( $course['lessons'] ) > 1 && self::has_lesson_rules( $course['id'], $rules ) ) { $open[ $course['id'] ] = true; } } $data_attr = array( 'courses' => $json, 'rules' => array_keys( $rules ), 'open' => array_keys( $open ), 'perPage' => $size, 'sizes' => $sizes, ); $html = '
'; $html .= self::legend(); if ( $data['total'] > $n ) { /* translators: 1: courses shown, 2: courses in all. */ $cut = sprintf( __( 'The grid shows the first %1$s of %2$s courses.', 'rooster-lms' ), Format::number( $n ), Format::number( $data['total'] ) ); $html .= Ui::kit_markup() ? Ui::notice( $cut, 'warning' ) : '

' . esc_html( $cut ) . '

'; } $html .= '
'; $html .= self::toolbar( $id, $n ); $html .= '
'; $html .= ''; $html .= self::head( $roles ) . ''; $last = array( null, null ); foreach ( 0 === $size ? $courses : array_slice( $courses, 0, $size ) as $course ) { $repeat = array( $course['top'] === $last[0], $course['top'] === $last[0] && $course['sub'] === $last[1] ); $last = array( $course['top'], $course['sub'] ); $html .= self::course_row( $course, $roles, $rules, $repeat, isset( $open[ $course['id'] ] ) ); } $html .= '
' . esc_html( $args['caption'] ?? __( 'Access matrix: courses and lessons by job role', 'rooster-lms' ) ) . '
'; $html .= '

' . esc_html( 0 === $n ? __( 'There are no courses.', 'rooster-lms' ) : __( 'No course matches the search.', 'rooster-lms' ) ) . '

'; $html .= self::footer( $id, $n, $size, $sizes ); $html .= '
'; $html .= ''; $html .= ''; $html .= '

'; $html .= ''; return $html . '
'; } private static function has_lesson_rules( int $course_id, array $rules ): bool { foreach ( array_keys( $rules ) as $key ) { $parts = explode( ':', $key ); if ( (int) $parts[1] === $course_id && '0' !== $parts[2] ) { return true; } } return false; } private static function lessons_badge( int $total ): string { /* translators: %s: number of lessons. */ $text = sprintf( _n( '%s lesson', '%s lessons', $total, 'rooster-lms' ), Format::number( $total ) ); return Ui::kit_markup() ? Ui::badge( $text, 'secondary sm' ) : '' . esc_html( $text ) . ''; } public static function l10n(): array { return array( 'all' => __( 'Whole course', 'rooster-lms' ), /* translators: 1: lessons ticked, 2: lessons of the course. */ 'some' => __( 'Only the ticked lessons (%1$d of %2$d)', 'rooster-lms' ), 'none' => __( 'Not assigned', 'rooster-lms' ), /* translators: %d: number of unsaved changes (always 1). */ 'dirtyOne' => _n( '%d unsaved change', '%d unsaved changes', 1, 'rooster-lms' ), /* translators: %d: number of unsaved changes. */ 'dirtyMany' => _n( '%d unsaved change', '%d unsaved changes', 2, 'rooster-lms' ), /* translators: %s: number of courses. */ 'countOne' => _n( '%s course', '%s courses', 1, 'rooster-lms' ), /* translators: %s: number of courses. */ 'countMany' => _n( '%s course', '%s courses', 2, 'rooster-lms' ), /* translators: 1: courses found, 2: courses of the grid. */ 'countSome' => __( '%1$s of %2$s courses', 'rooster-lms' ), /* translators: %s: number of lessons. */ 'lessons' => _n( '%s lesson', '%s lessons', 2, 'rooster-lms' ), /* translators: %s: course title. */ 'lessonsOf' => __( 'Lessons of %s', 'rooster-lms' ), /* translators: 1: job role, 2: course title. */ 'courseLabel' => __( '%1$s: whole course %2$s', 'rooster-lms' ), /* translators: 1: job role, 2: lesson title. */ 'lessonLabel' => __( '%1$s: lesson %2$s', 'rooster-lms' ), /* translators: 1: first row, 2: last row, 3: rows in all. */ 'showing' => __( 'Showing %1$s–%2$s of %3$s', 'rooster-lms' ), 'first' => __( 'First page', 'rooster-lms' ), 'previous' => __( 'Previous page', 'rooster-lms' ), 'next' => __( 'Next page', 'rooster-lms' ), 'last' => __( 'Last page', 'rooster-lms' ), /* translators: %s: page number. */ 'page' => __( 'Page %s', 'rooster-lms' ), 'saved' => __( 'Access matrix saved.', 'rooster-lms' ), 'discarded' => __( 'Changes discarded.', 'rooster-lms' ), ); } private static function state_text( string $state, int $count, int $total ): string { if ( 'all' === $state ) { return __( 'Whole course', 'rooster-lms' ); } if ( 'some' === $state ) { /* translators: 1: lessons ticked, 2: lessons of the course. */ return sprintf( __( 'Only the ticked lessons (%1$d of %2$d)', 'rooster-lms' ), $count, $total ); } return __( 'Not assigned', 'rooster-lms' ); } private static function legend(): string { return ''; } private static function toolbar( string $id, int $courses ): string { return '
' . TreeTable::search_field( $id, 'rooster-lms-matrix', 'data-rl-matrix', __( 'Search courses', 'rooster-lms' ), __( 'Search courses, lessons, categories…', 'rooster-lms' ) ) /* translators: %s: number of courses. */ . '' . esc_html( sprintf( _n( '%s course', '%s courses', $courses, 'rooster-lms' ), Format::number( $courses ) ) ) . '' . TreeTable::expand_tools( 'rooster-lms-matrix', 'data-rl-matrix' ) . '
'; } private static function head( array $roles ): string { $html = '' . '' . esc_html__( 'Top category', 'rooster-lms' ) . '' . '' . esc_html__( 'Sub-category', 'rooster-lms' ) . '' . '' . esc_html__( 'Course', 'rooster-lms' ) . '' . '' . esc_html__( 'Lesson', 'rooster-lms' ) . ''; foreach ( $roles as $role_id => $name ) { $html .= '' . '' . esc_html( $name ) . '' . '' /* translators: %s: job role. */ . '' /* translators: %s: job role. */ . '' . ''; } return $html . ''; } private static function clip( string $text ): string { return '' === $text ? '' : '' . esc_html( $text ) . ''; } private static function course_row( array $course, array $roles, array $rules, array $repeat, bool $open ): string { $id = $course['id']; $total = count( $course['lessons'] ); $ticked = array(); foreach ( array_keys( $rules ) as $key ) { $parts = array_map( 'intval', explode( ':', $key ) ); if ( $parts[1] === $id && $parts[2] > 0 ) { $ticked[ $parts[0] ] = ( $ticked[ $parts[0] ] ?? 0 ) + 1; } } $html = '' . '' . self::clip( $course['top'] ) . '' . '' . self::clip( $course['sub'] ) . '' . '' . esc_html( $course['title'] ) . ''; if ( $total > 1 ) { $html .= ''; } $html .= ''; foreach ( $roles as $role_id => $role_name ) { $key = $role_id . ':' . $id . ':0'; $on = isset( $rules[ $key ] ); $count = $ticked[ $role_id ] ?? 0; $state = $on ? 'all' : ( $count > 0 ? 'some' : 'none' ); $html .= ''; } $html .= ''; if ( $open ) { foreach ( $course['lessons'] as $lesson ) { $html .= self::lesson_row( $course, $lesson, $roles, $rules ); } } return $html; } private static function lesson_row( array $course, array $lesson, array $roles, array $rules ): string { $html = '' . '' . self::clip( $course['top'] ) . '' . '' . self::clip( $course['sub'] ) . '' . '' . self::clip( $course['title'] ) . '' . '' . esc_html( $lesson[1] ) . ''; foreach ( $roles as $role_id => $role_name ) { $key = $role_id . ':' . $course['id'] . ':' . $lesson[0]; $on = isset( $rules[ $key ] ); $whole = isset( $rules[ $role_id . ':' . $course['id'] . ':0' ] ); $html .= ''; } return $html . ''; } private static function footer( string $id, int $n, int $size, array $sizes ): string { $pages = 0 === $size ? 1 : max( 1, (int) ceil( $n / $size ) ); $to = 0 === $size ? $n : min( $n, $size ); $options = ''; foreach ( $sizes as $s ) { $options .= ''; } return ''; } public static function pager( int $page, int $pages ): string { if ( $pages <= 1 ) { return ''; } $step = static function ( string $glyph, string $label, int $target, bool $on ): string { return $on ? '
  • ' : '
  • ' . esc_html( $glyph ) . '
  • '; }; $items = $step( '«', __( 'First page', 'rooster-lms' ), 1, $page > 1 ) . $step( '‹', __( 'Previous page', 'rooster-lms' ), $page - 1, $page > 1 ); $shown = array_values( array_unique( array_filter( array( 1, $page - 1, $page, $page + 1, $pages ), static fn( int $p ): bool => $p >= 1 && $p <= $pages ) ) ); sort( $shown ); $last = 0; foreach ( $shown as $p ) { if ( $p - $last > 1 ) { $items .= '
  • '; } /* translators: %s: page number. */ $items .= '
  • '; $last = $p; } $items .= $step( '›', __( 'Next page', 'rooster-lms' ), $page + 1, $page < $pages ) . $step( '»', __( 'Last page', 'rooster-lms' ), $pages, $page < $pages ); return ''; } }