get( $course_id ); return null !== $course && $course->home_post_id > 0 && Users::can( $user_id, 'edit_post', $course->home_post_id ); } public static function full( PrereqPath $path, string $lang, int $heading = 2, string $copy = '' ): string { \RoosterLMS\UI\Assets::enqueue_part( \RoosterLMS\UI\Assets::PATH ); if ( ! self::has_full( $path ) ) { return ''; } $course = $path->course_id; $base = 'rooster-lms-path-' . $course . $copy; $summary = $path->summary(); $state = $summary->cycle ? 'cycle' : ( $summary->hidden_only ? 'hidden' : 'locked' ); if ( '' === $copy ) { self::mark( 'course:' . $course ); } $title = esc_html__( 'How to unlock this course', 'rooster-lms' ); $head = $heading >= 2 && $heading <= 6 ? '' . $title . '' : '

' . $title . '

'; $nodes = ''; foreach ( $path->nodes as $node ) { $nodes .= self::node( $path, $node, $lang ); } return '
' . $head . '

' . self::summary( $path ) . '

' . ( '' !== $nodes ? '
    ' . $nodes . '
' : '' ) . ( $path->truncated ? '

' . esc_html__( '…and more courses.', 'rooster-lms' ) . '

' : '' ) . '
'; } public static function has_full( PrereqPath $path ): bool { return $path->locked && ( array() !== $path->nodes || PathLock::CYCLE === $path->root_lock ); } public static function compact( PrereqPath $path, string $lang, string $context, string $id_base, string $url = '', bool $copy = false ): string { \RoosterLMS\UI\Assets::enqueue_part( \RoosterLMS\UI\Assets::PATH ); if ( ! $path->locked ) { return ''; } if ( 'outline' !== $context ) { return self::lock_line( $path, $lang, 'row' === $context ? 'rooster-lms-my-courses__lock' : 'rooster-lms-card__lock', $id_base, $url ); } $course = $path->course_id; $summary = $path->summary(); $sentence = self::sentence( $path ); $next = $path->next(); if ( ! $summary->cycle && null !== $next && ! in_array( $course, $next->needed_for, true ) ) { /* translators: %s: the title of the course (or lesson) to start with, as a link. */ $sentence .= ' ' . sprintf( esc_html__( 'Start here: %s.', 'rooster-lms' ), self::course_link( $next, $lang ) ); } $html = '

' . $sentence; $n = count( $path->nodes ); if ( 0 === $n ) { return $html . '

'; } if ( self::rendered( $course ) ) { /* translators: %s: number of steps (courses) of the path that unlocks a course. */ $label = sprintf( _n( 'How to unlock: %s step', 'How to unlock: all %s steps', $n, 'rooster-lms' ), Format::number( $n ) ); return $html . ' ' . esc_html( $label ) . '

'; } /* translators: %s: number of steps (courses) of the path that unlocks a course. */ $label = sprintf( _n( 'Show the %s step', 'Show all %s steps', $n, 'rooster-lms' ), Format::number( $n ) ); return $html . '

' . esc_html( $label ) . '' . self::full( $path, $lang, 0, $copy ? '-outline' : '' ) . '
'; } public static function item_short( UnlockExplanation $explanation, string $lang, string $course_href = '' ): string { \RoosterLMS\UI\Assets::enqueue_part( \RoosterLMS\UI\Assets::PATH ); if ( ! $explanation->locked ) { return ''; } $step = $explanation->steps[0] ?? null; if ( null === $step ) { return esc_html( self::reason_text( $explanation->reason ) ); } switch ( $step->kind ) { case UnlockCondition::COURSE: return esc_html__( 'Complete the required courses first.', 'rooster-lms' ) . self::why_link( $course_href, __( 'How to unlock this course', 'rooster-lms' ) ); case UnlockCondition::PREVIOUS: $html = esc_html( self::previous_text( $step ) ); $start = $explanation->start_item(); if ( $start > 0 ) { $link = self::item_link( $explanation->course_id, $start, $lang, true ); if ( '' !== $link ) { /* translators: %s: the title of the course (or lesson) to start with, as a link. */ $html .= ' ' . sprintf( esc_html__( 'Start here: %s.', 'rooster-lms' ), $link ); } } return $html; default: return esc_html( self::opens_text( $step->opens_at ) ); } } public static function item_full( UnlockExplanation $explanation, string $lang, int $heading = 2, string $copy = '', string $course_href = '' ): string { \RoosterLMS\UI\Assets::enqueue_part( \RoosterLMS\UI\Assets::PATH ); if ( ! $explanation->locked ) { return ''; } $base = 'rooster-lms-unlock-' . $explanation->item_id . $copy; $full = $heading >= 2 && $heading <= 6; if ( '' === $copy ) { self::mark( 'item:' . $explanation->item_id ); } $steps = ''; foreach ( $explanation->steps as $step ) { $steps .= '
  • ' . self::step( $explanation, $step, $lang, $full ? $heading + 1 : 0, '' === $copy ? null : $course_href ) . '
  • '; } if ( '' === $steps ) { $steps = '
  • ' . esc_html( self::reason_text( $explanation->reason ) ) . '
  • '; } $intro = $explanation->preview ? '

    ' . esc_html__( 'You can read this preview. To complete it:', 'rooster-lms' ) . '

    ' : ''; $body = $intro . '
      ' . $steps . '
    '; if ( ! $full ) { return '
    ' . $body . '
    '; } return '
    ' . '' . esc_html__( 'How to unlock this lesson', 'rooster-lms' ) . '' . $body . '
    '; } public static function guest( int $course_id ): string { \RoosterLMS\UI\Assets::enqueue_part( \RoosterLMS\UI\Assets::PATH ); if ( array() === Services::gate()->missing_prerequisites( 0, $course_id ) ) { return ''; } return Markup::login_prompt( __( 'This course has prerequisites. Log in to see what to complete first.', 'rooster-lms' ) ); } public static function staff( int $course_id, string $lang ): string { \RoosterLMS\UI\Assets::enqueue_part( \RoosterLMS\UI\Assets::PATH ); $user_id = get_current_user_id(); $required = Services::prerequisite_repo()->for_courses( array( $course_id ) )[ $course_id ] ?? array(); $ids = array_map( static fn( $p ): int => $p->requires_course_id, $required ); $courses = array() === $ids ? array() : Services::course_repo()->get_many( $ids ); $visible = array(); $hidden = false; $gate = Services::gate(); foreach ( $required as $p ) { $course = $courses[ $p->requires_course_id ] ?? null; if ( null === $course || ! $course->is_active() ) { continue; } if ( $gate->can_view_course( $user_id, $p->requires_course_id )->allowed ) { $visible[] = $p; } else { $hidden = true; } } $posts = array() === $visible ? array() : Services::posts()->for_objects( ObjectType::COURSE, array_map( static fn( $p ): int => $p->requires_course_id, $visible ), $lang ); $items = ''; foreach ( $visible as $p ) { $post_id = $posts[ $p->requires_course_id ] ?? 0; if ( $post_id <= 0 ) { $hidden = true; continue; } $url = Urls::course_url( $p->requires_course_id, $lang ); $title = esc_html( Services::posts()->title( $post_id ) ); $attr = Markup::lang_attr( Services::posts()->language_of( $post_id ), $lang ); $items .= '
  • ' . ( '' !== $url ? '' . $title . '' : '' . $title . '' ) . ' ' . esc_html( self::rule_text( $p->rule, $p->min_percent ) ) . '
  • '; } if ( $hidden ) { $items .= self::hidden_entry(); } if ( '' === $items ) { return ''; } return '

    ' . esc_html__( 'Learners must first complete these courses (you can open this course as staff):', 'rooster-lms' ) . '

    '; } private static function summary( PrereqPath $path ): string { if ( $path->summary()->cycle ) { return self::sentence( $path ); } $n = count( $path->nodes ); $count = 1 === $n ? __( 'Complete this course first.', 'rooster-lms' ) : sprintf( /* translators: %s: number of courses to complete before a course unlocks (2 or more). */ _n( 'Complete these %s course in this order.', 'Complete these %s courses in this order.', $n, 'rooster-lms' ), Format::number( $n ) ); return self::sentence( $path ) . ' ' . esc_html( $count . ' ' . sprintf( /* translators: 1: courses of the path already done, 2: courses in the path. */ _n( '%1$s of %2$s done.', '%1$s of %2$s done.', $n, 'rooster-lms' ), Format::number( $path->done() ), Format::number( $n ) ) ) . ''; } public static function sentence( PrereqPath $path ): string { if ( $path->summary()->cycle ) { return esc_html( self::cycle_text() ); } $titles = array(); foreach ( Services::gate()->missing_prerequisites( $path->user_id, $path->course_id ) as $id ) { $node = $path->node( $id ); if ( null !== $node && ! $node->hidden && '' !== $node->title ) { $titles[] = '' . esc_html( $node->title ) . ''; } } if ( array() === $titles ) { return esc_html__( 'Complete the required courses first.', 'rooster-lms' ); } /* translators: %s: the titles of one or more courses, as a list ("A, B and C"). */ return sprintf( esc_html__( 'Complete %s first.', 'rooster-lms' ), wp_sprintf( '%l', $titles ) ); } private static function node( PrereqPath $path, PathNode $node, string $lang ): string { if ( $node->hidden ) { return self::hidden_entry(); } $classes = array( 'rooster-lms-path__node', 'is-' . str_replace( '_', '-', $node->progress->value ) ); if ( $node->met ) { $classes[] = 'is-met'; } if ( PathLock::CYCLE === $node->lock ) { $classes[] = 'is-unavailable'; } elseif ( PathLock::PREREQUISITES === $node->lock ) { $classes[] = 'is-locked'; } if ( $node->next ) { $classes[] = 'is-next'; } $html = ''; if ( $node->next ) { $html .= '' . ( PathProgress::IN_PROGRESS === $node->progress ? esc_html__( 'Continue here', 'rooster-lms' ) : esc_html__( 'Start here', 'rooster-lms' ) ) . ' '; } $html .= self::course_link( $node, $lang ); list( $icon, $status ) = self::status( $node ); $html .= ' ' . esc_html( $status ) . ''; $html .= ' ' . esc_html( self::rule_text( $node->rule, $node->min_percent ) ) . ''; if ( PathLock::CYCLE === $node->lock ) { $html .= ' ' . esc_html( self::cycle_text() ) . ''; } elseif ( PathLock::PREREQUISITES === $node->lock ) { $names = array_map( static fn( int $id ): string => self::name( $path, $id ), $node->requires ); $html .= ' ' /* translators: %s: one or more course names, as a list ("A, B and C"). */ . sprintf( esc_html__( 'Locked: first complete %s.', 'rooster-lms' ), array() === $names ? esc_html__( 'the required courses', 'rooster-lms' ) : wp_sprintf( '%l', array_map( 'esc_html', $names ) ) ) . ''; } $for = array_map( static fn( int $id ): string => self::name( $path, $id ), $node->needed_for ); if ( array() !== $for ) { /* translators: %s: one or more course names, as a list ("A and this course"). */ $html .= ' ' . sprintf( esc_html__( 'Needed for %s', 'rooster-lms' ), wp_sprintf( '%l', array_map( 'esc_html', $for ) ) ) . ''; } $access = self::access_text( $node ); if ( '' !== $access ) { $html .= ' ' . esc_html( $access ) . ''; } return '
  • next ? ' aria-current="step"' : '' ) . '>' . $html . '
  • '; } private static function hidden_entry(): string { return ''; } private static function lock_line( PrereqPath $path, string $lang, string $css, string $id_base, string $url ): string { $summary = $path->summary(); $first = $summary->first; if ( $summary->cycle ) { $text = esc_html__( "Locked — can't be unlocked right now. Please tell your manager.", 'rooster-lms' ); } elseif ( null === $first || $first->hidden ) { $text = esc_html__( "Locked — you need a course you don't have access to yet. Ask your manager.", 'rooster-lms' ); } elseif ( PrereqRule::PERCENT === $first->rule ) { /* translators: 1: a percentage, 2: a course title (a link). */ $text = sprintf( esc_html__( 'Locked — first reach %1$s in %2$s', 'rooster-lms' ), esc_html( Format::percent( $first->min_percent ) ), self::course_link( $first, $lang ) ); } else { /* translators: %s: a course title (a link). */ $text = sprintf( esc_html__( 'Locked — first complete %s', 'rooster-lms' ), self::course_link( $first, $lang ) ); } if ( $summary->more > 0 ) { $url = '' !== $url ? $url : Urls::course_url( $path->course_id, $lang ); if ( '' !== $url ) { /* translators: %s: number of further courses to complete. */ $label = sprintf( _n( '+%s more', '+%s more', $summary->more, 'rooster-lms' ), Format::number( $summary->more ) ); $text .= ' ' . esc_html( $label ) . ''; } } return '

    ' . $text . '

    '; } private static function course_link( PathNode $node, string $lang ): string { $title = esc_html( $node->title ); $attr = Markup::lang_attr( $node->title_lang, $lang ); $url = $node->hidden ? '' : Urls::course_url( $node->course_id, $lang ); return '' !== $url ? '' . $title . '' : '' . $title . ''; } private static function name( PrereqPath $path, int $id ): string { if ( $id === $path->course_id ) { return __( 'this course', 'rooster-lms' ); } $node = 0 === $id ? null : $path->node( $id ); return null === $node || $node->hidden ? __( "a course you don't have access to yet", 'rooster-lms' ) : $node->title; } private static function status( PathNode $node ): array { if ( PathLock::CYCLE === $node->lock ) { return array( 'unavailable', __( 'Unavailable', 'rooster-lms' ) ); } if ( PathProgress::COMPLETED === $node->progress ) { return array( 'done', __( 'Completed', 'rooster-lms' ) ); } if ( $node->met ) { /* translators: %s: a percentage the learner reached. */ return array( 'done', sprintf( __( 'Reached %s', 'rooster-lms' ), Format::percent( $node->percent ) ) ); } if ( PathProgress::IN_PROGRESS === $node->progress ) { /* translators: %s: a percentage of the course done. */ return array( 'progress', sprintf( __( 'In progress, %s', 'rooster-lms' ), Format::percent( $node->percent ) ) ); } return array( 'todo', __( 'Not started', 'rooster-lms' ) ); } private static function rule_text( PrereqRule $rule, int $min ): string { /* translators: %s: the percentage a required course must reach. */ return PrereqRule::PERCENT === $rule ? sprintf( __( 'At least %s', 'rooster-lms' ), Format::percent( $min ) ) : __( 'Must be completed', 'rooster-lms' ); } private static function access_text( PathNode $node ): string { if ( $node->met ) { return ''; } if ( PathAccess::ENDED === $node->access ) { return __( 'Your access to this course has ended.', 'rooster-lms' ); } if ( null !== $node->lock ) { return ''; } return match ( $node->access ) { PathAccess::CAN_ENROL => __( 'You can enrol yourself.', 'rooster-lms' ), PathAccess::ASK => __( 'Ask your manager for access.', 'rooster-lms' ), default => '', }; } private static function cycle_text(): string { return __( "This course can't be unlocked right now because of a setup problem. Please tell your manager.", 'rooster-lms' ); } private static function step( UnlockExplanation $explanation, UnlockStep $step, string $lang, int $heading, ?string $course_href = null ): string { switch ( $step->kind ) { case UnlockCondition::COURSE: $html = esc_html__( 'Complete the required courses first.', 'rooster-lms' ); if ( null === $step->path || ! self::has_full( $step->path ) ) { return $html; } if ( self::rendered( $step->path->course_id ) ) { return $html . self::why_link( '#rooster-lms-path-' . $step->path->course_id, __( 'How to unlock this course', 'rooster-lms' ) ); } if ( null !== $course_href ) { return $html . self::why_link( $course_href, __( 'How to unlock this course', 'rooster-lms' ) ); } return $html . self::full( $step->path, $lang, $heading ); case UnlockCondition::PREVIOUS: return esc_html( self::previous_text( $step ) ) . self::chain( $explanation, $lang, $heading > 0 ? self::CHAIN_FULL : self::CHAIN_COMPACT ); case UnlockCondition::DAYS: return self::opens_full( $step->opens_at, $step->days ); default: return self::opens_full( $step->opens_at, null ); } } private static function chain( UnlockExplanation $explanation, string $lang, array $keep ): string { $chain = array(); $seen = array(); $cur = $explanation; while ( ! isset( $seen[ $cur->item_id ] ) ) { $seen[ $cur->item_id ] = true; $step = $cur->step( UnlockCondition::PREVIOUS ); if ( null === $step ) { break; } $chain[] = $step; if ( null === $step->then ) { break; } $cur = $step->then; } $chain = array_reverse( $chain ); $start = $explanation->start_item(); $total = count( $chain ); $html = ''; foreach ( $chain as $i => $step ) { if ( $total > $keep[0] + $keep[1] && $i >= $keep[0] && $i < $total - $keep[1] ) { if ( $i === $keep[0] ) { $html .= ''; } continue; } $html .= self::chain_entry( $explanation->course_id, $step, $step->item_id === $start, $lang ); } return '' === $html ? '' : '
      ' . $html . '
    '; } private static function chain_entry( int $course_id, UnlockStep $step, bool $start, string $lang ): string { $status = $step->status ?? ProgressStatus::NOT_STARTED; $icon = ProgressStatus::COMPLETED === $status ? 'done' : ( ProgressStatus::IN_PROGRESS === $status ? 'progress' : 'todo' ); $html = $start ? '' . esc_html__( 'Start here', 'rooster-lms' ) . ' ' : ''; $html .= self::item_link( $course_id, $step->item_id, $lang, ! $start || null === $step->then ); $html .= ' ' . esc_html( $status->label() ) . ''; if ( $step->is_quiz ) { $html .= ' ' . esc_html( QuizCompletion::ATTEMPT === $step->completion ? __( 'Must be submitted', 'rooster-lms' ) : __( 'Must be passed', 'rooster-lms' ) ) . ''; } $locks = array(); foreach ( null === $step->then ? array() : $step->then->steps as $own ) { if ( UnlockCondition::PREVIOUS === $own->kind ) { /* translators: %s: one or more course names, as a list ("A, B and C"). */ $locks[] = sprintf( esc_html__( 'Locked: first complete %s.', 'rooster-lms' ), self::item_link( $course_id, $own->item_id, $lang, false ) ); } elseif ( UnlockCondition::DAYS === $own->kind || UnlockCondition::DATE === $own->kind ) { $locks[] = self::opens_full( $own->opens_at, UnlockCondition::DAYS === $own->kind ? $own->days : null ); } } if ( array() !== $locks ) { $html .= ' ' . implode( ' ', $locks ) . ''; } $classes = 'rooster-lms-why__item' . ( $start ? ' is-next' : '' ) . ( null !== $step->then ? ' is-locked' : '' ); return '
  • ' . $html . '
  • '; } private static function item_link( int $course_id, int $item_id, string $lang, bool $link ): string { $item = Services::curriculum_repo()->items( array( $item_id ) )[ $item_id ] ?? null; if ( null === $item ) { return ''; } $post_id = Services::posts()->for_objects( ObjectType::UNIT, array( $item->unit_id ), $lang )[ $item->unit_id ] ?? 0; if ( $post_id <= 0 ) { return ''; } $title = esc_html( Services::posts()->title( $post_id ) ); $attr = Markup::lang_attr( Services::posts()->language_of( $post_id ), $lang ); $url = $link && Services::gate()->can_open_item( get_current_user_id(), $item_id )->allowed ? Urls::item_url( $course_id, $item_id, $lang ) : ''; return '' !== $url ? '' . $title . '' : '' . $title . ''; } private static function previous_text( UnlockStep $step ): string { if ( ! $step->is_quiz ) { return __( 'Complete the previous lesson first.', 'rooster-lms' ); } return QuizCompletion::ATTEMPT === $step->completion ? __( 'Submit the previous quiz first.', 'rooster-lms' ) : __( 'Pass the previous quiz first.', 'rooster-lms' ); } private static function opens_text( ?string $utc ): string { $date = Format::date( $utc ); /* translators: %s: the date a lesson opens. */ return '' !== $date ? sprintf( __( 'Opens on %s.', 'rooster-lms' ), $date ) : __( 'Not open yet.', 'rooster-lms' ); } private static function opens_full( ?string $utc, ?int $days ): string { $date = Format::date( $utc ); $stamp = null === $utc ? false : strtotime( $utc . ' UTC' ); if ( '' === $date || false === $stamp ) { return esc_html__( 'Not open yet.', 'rooster-lms' ); } $time = ''; $at = Format::date( $utc, (string) get_option( 'time_format' ) ); $midnight = '00:00' === Format::date( $utc, 'H:i' ); if ( null === $days ) { return $midnight /* translators: %s: the date a lesson opens. */ ? sprintf( esc_html__( 'Opens on %s.', 'rooster-lms' ), $time ) /* translators: 1: the date a lesson opens, 2: the time of day it opens. */ : sprintf( esc_html__( 'Opens on %1$s at %2$s.', 'rooster-lms' ), $time, esc_html( $at ) ); } if ( $midnight ) { /* translators: 1: the date a lesson opens, 2: number of days after the learner enrolled. */ return sprintf( esc_html( _n( 'Opens on %1$s, %2$s day after you enrolled.', 'Opens on %1$s, %2$s days after you enrolled.', $days, 'rooster-lms' ) ), $time, esc_html( Format::number( $days ) ) ); } /* translators: 1: the date a lesson opens, 2: the time of day it opens, 3: number of days after the learner enrolled. */ return sprintf( esc_html( _n( 'Opens on %1$s at %2$s, %3$s day after you enrolled.', 'Opens on %1$s at %2$s, %3$s days after you enrolled.', $days, 'rooster-lms' ) ), $time, esc_html( $at ), esc_html( Format::number( $days ) ) ); } private static function reason_text( ?AccessReason $reason ): string { return match ( $reason ) { AccessReason::LOCKED_PREREQUISITE => __( 'Complete the required courses first.', 'rooster-lms' ), AccessReason::LOCKED_SEQUENCE => __( 'Complete the previous lesson first.', 'rooster-lms' ), default => __( 'Not open yet.', 'rooster-lms' ), }; } private static function why_link( string $href, string $text ): string { return '' === $href ? '' : ' ' . esc_html( $text ) . ''; } private static function flags(): array { $key = Services::prerequisite_paths(); if ( null === self::$printed || ! isset( self::$printed[ $key ] ) ) { return array(); } return self::$printed[ $key ]; } private static function mark( string $flag ): void { if ( null === self::$printed ) { self::$printed = new \WeakMap(); } $key = Services::prerequisite_paths(); $flags = self::$printed[ $key ] ?? array(); $flags[ get_current_user_id() . ':' . $flag ] = true; self::$printed[ $key ] = $flags; } }