$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 .= self::footer( $id, $n, $size, $sizes );
$html .= '
';
$html .= '
';
$html .= '
'
. '' . esc_html__( 'No unsaved changes', 'rooster-lms' ) . ''
. ''
. $args['save'] . '
';
$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 ''
. '- ' . esc_html__( 'Course ticked: the job role gets the whole course.', 'rooster-lms' ) . '
'
. '- ' . esc_html__( 'Lessons ticked instead: only those lessons; the others are hidden and do not count towards completion.', 'rooster-lms' ) . '
'
. '- ' . esc_html__( 'Empty: not assigned.', 'rooster-lms' ) . '
';
}
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 '';
}
}