delivery ) { $items['delivery'] = __( 'One question at a time.', 'rooster-lms' ); $limits = self::limits( $p, $unit_id ); if ( null !== $limits ) { $items['time'] = self::limits_text( $limits ); if ( $limits['factor'] > 100 ) { $items['extra'] = sprintf( /* translators: %s: the learner's extra time in percent, e.g. 50%. */ __( 'You have %s extra time on each question.', 'rooster-lms' ), Format::percent( $limits['factor'] - 100 ) ); } $items['timeout'] = __( 'When the time for a question runs out, the answer you have chosen is submitted; with nothing chosen the question counts as not answered. A question cannot be restarted.', 'rooster-lms' ); } elseif ( $p->timed && 0 === $p->time_factor ) { $items['time'] = __( 'No time limit applies to you.', 'rooster-lms' ); } $items['back'] = $p->forward_only() ? __( 'You cannot go back to an earlier question.', 'rooster-lms' ) : __( 'You can go back and change your answers until you finish.', 'rooster-lms' ); } if ( Disclosure::AFTER_EACH_QUESTION === $p->disclosure ) { $items['results'] = __( 'You see the correct answer after each question.', 'rooster-lms' ); } elseif ( Disclosure::AFTER_WINDOW === $p->disclosure && null !== $p->release_at ) { $items['results'] = $p->class_release ? sprintf( /* translators: %s: date and time the results are published. */ __( 'Results are published for your class on %s.', 'rooster-lms' ), self::when( $p->release_at ) ) : sprintf( /* translators: %s: date and time the learner's exam window closes. */ __( 'Results are published when your exam window closes on %s.', 'rooster-lms' ), self::when( $p->release_at ) ); } if ( WindowKind::NONE !== $p->window->kind && null !== $p->closes_at ) { $items['window'] = sprintf( /* translators: %s: date and time the exam window closes. */ __( 'The exam is open until %s.', 'rooster-lms' ), self::when( $p->closes_at ) ); } if ( array() === $items ) { return ''; } $html = ''; foreach ( $items as $key => $text ) { $html .= '
  • ' . esc_html( $text ) . '
  • '; } return ''; } public static function limits( EffectivePolicy $p, int $unit_id ): ?array { $own = array(); $base = array(); $none = 0; foreach ( Services::quiz_repo()->questions( $unit_id ) as $q ) { $ms = $p->limit_ms( $q['time_limit'] ?? null, $q['type'] ); if ( $ms <= 0 ) { ++$none; continue; } $own[] = intdiv( $ms, 1000 ); $base[] = (int) ( $q['time_limit'] ?? $p->question_time_sec ?? 0 ); } if ( array() === $own ) { return null; } return array( 'min' => min( $own ), 'max' => max( $own ), 'base_min' => min( $base ), 'base_max' => max( $base ), 'timed' => count( $own ), 'untimed' => $none, 'factor' => $p->time_factor, ); } private static function limits_text( array $l ): string { $text = $l['min'] === $l['max'] ? sprintf( /* translators: %s: a duration, e.g. "30 seconds". */ __( 'Time for each question: %s.', 'rooster-lms' ), self::duration( $l['max'] ) ) : sprintf( /* translators: 1: shortest duration, 2: longest duration, e.g. "20 seconds", "1 minute". */ __( 'Time per question: %1$s to %2$s.', 'rooster-lms' ), self::duration( $l['min'] ), self::duration( $l['max'] ) ); if ( $l['untimed'] > 0 ) { $text .= ' ' . sprintf( /* translators: %s: number of questions without a time limit. */ _n( '%s question has no time limit.', '%s questions have no time limit.', $l['untimed'], 'rooster-lms' ), Format::number( $l['untimed'] ) ); } return $text; } public static function duration( int $seconds ): string { $seconds = max( 0, $seconds ); $m = intdiv( $seconds, 60 ); $s = $seconds % 60; /* translators: %s: number of seconds. */ $sec = sprintf( _n( '%s second', '%s seconds', $s, 'rooster-lms' ), Format::number( $s ) ); if ( 0 === $m ) { return $sec; } /* translators: %s: number of minutes. */ $min = sprintf( _n( '%s minute', '%s minutes', $m, 'rooster-lms' ), Format::number( $m ) ); return 0 === $s ? $min : $min . ' ' . $sec; } public static function window_notice( string $why, EffectivePolicy $p ): string { if ( 'window_closed' === $why ) { $text = null === $p->closes_at ? __( 'The exam window has closed.', 'rooster-lms' ) /* translators: %s: date and time the exam window closed. */ : sprintf( __( 'The exam window closed on %s.', 'rooster-lms' ), self::when( $p->closes_at ) ); } else { $text = null === $p->opens_at ? __( 'The exam window is not open yet. It opens once the lessons before it are available to you.', 'rooster-lms' ) /* translators: %s: date and time the exam window opens. */ : sprintf( __( 'The exam opens on %s.', 'rooster-lms' ), self::when( $p->opens_at ) ); } return '

    ' . esc_html( $text ) . '

    '; } public static function withheld( Attempt $a ): string { $view = Services::learner_view(); $class = $view->class_release( $a ); $may = $view->may_continue( $a ); $text = $class ? ReviewRenderer::withheld_text( $a ) : sprintf( /* translators: 1: date and time the answers were submitted, 2: date and time the learner's exam window closes. */ __( 'Your answers were submitted on %1$s. Results will be published when your exam window closes on %2$s.', 'rooster-lms' ), null === $a->submitted_at ? '' : self::when( $a->submitted_at ), self::when( (string) $a->release_at ) ); $title = sprintf( /* translators: %s: attempt number. */ __( 'Attempt %s', 'rooster-lms' ), Format::number( $a->attempt_no ) ); $next = $may ? __( 'You can continue with the next lesson.', 'rooster-lms' ) : __( 'The next lesson opens when results are published.', 'rooster-lms' ); return '
    ' . '

    ' . esc_html( $title ) . '

    ' . '

    ' . esc_html( $text ) . '

    ' . '

    ' . esc_html( $next ) . '

    ' . '
    '; } public static function released_notice( Attempt $a ): string { if ( null === $a->release_at || null === $a->released_at ) { return ''; } $at = strtotime( $a->released_at . ' UTC' ); if ( false === $at || Clock::timestamp() - $at > self::RELEASED_NOTICE_DAYS * DAY_IN_SECONDS ) { return ''; } return '

    ' . esc_html( sprintf( /* translators: %s: date and time the results were published. */ __( 'Results published on %s.', 'rooster-lms' ), self::when( $a->released_at ) ) ) . '

    '; } public static function step( Attempt $a, QuestionStep $step, string $lang ): array { $key = $a->id . '-' . $step->seq; $ext = (array) ( $step->to_array()['ext'] ?? array() ); $final = ! $step->can_back && ! empty( $step->answered[ $step->seq ] ); $answers = Services::attempt_repo()->answers( $a->id ); $feedback = ''; $order = array(); if ( Disclosure::AFTER_EACH_QUESTION === $a->disclosure && $step->seq > 1 ) { $previous = self::question_at( $answers, $step->seq - 1 ); if ( null !== $previous ) { $feedback = self::feedback( $a, $previous, $answers, $lang ); } } $position = sprintf( /* translators: 1: question number, 2: number of questions. */ __( 'Question %1$s of %2$s', 'rooster-lms' ), Format::number( $step->seq ), Format::number( $step->total ) ); $done_pct = $step->total > 0 ? (int) floor( ( $step->seq - 1 ) * 100 / $step->total ) : 0; $html = $feedback . '
    ' . esc_html( $position ) . '' . '
    '; $timed = null !== $step->remaining_ms && $step->limit_ms > 0 && ! $final; $remaining = $timed ? max( 0, (int) $step->remaining_ms ) : -1; if ( $timed ) { $html .= self::countdown( $remaining, $step->limit_ms ); } $html .= '

    '; if ( null === $step->question || $final ) { $text = $final && null !== $step->question ? __( 'Time is up for this question.', 'rooster-lms' ) : __( 'Your next question is ready.', 'rooster-lms' ); $html .= '

    ' . esc_html( $text ) . '

    ' . '

    '; } else { $q = $step->question; $view = new QuizView( $a->unit_id, array( $q ), $q->points ); $version = 0; foreach ( $answers as $question_id => $answer ) { if ( $question_id === $q->id ) { $version = AttemptRepository::shown( $answer ); } } $values = array(); if ( $step->can_back && isset( $answers[ $q->id ] ) && null !== $answers[ $q->id ]['answered_at'] ) { $values[ $q->uuid ] = (array) $answers[ $q->id ]['response']; } $types = array(); $contents = array(); foreach ( Services::quiz_repo()->versions( array_map( array( AttemptRepository::class, 'shown' ), $answers ) ) as $row ) { if ( isset( $row['key'] ) ) { $types[ $row['key']->type->slug() ] = true; if ( is_array( $row['key']->h5p ?? null ) && (int) ( $row['key']->h5p['content_id'] ?? 0 ) > 0 ) { $contents[] = (int) $row['key']->h5p['content_id']; } } } if ( array() !== $contents ) { \RoosterLMS\H5P\Engine::enqueue_contents( $contents ); } \RoosterLMS\H5P\Engine::enqueue_question_widgets( \RoosterLMS\H5P\Engine::question_widget_libraries( array_keys( $types ) ) ); $form = QuizMarkup::form( $view, QuizMarkup::gap_uuids( $view, array( $q->id => $version ) ), false, array( 'seq' => $step->seq, 'total' => $step->total, 'values' => $values, 'heading' => true, 'widgets' => Services::quizzes()->settings( $a->unit_id )->widgets, ) ); $last = $step->seq >= $step->total; $label = $last ? __( 'Finish', 'rooster-lms' ) : __( 'Next', 'rooster-lms' ); $back = $step->can_back && $step->seq > 1 ? ' ' : ''; $html .= '' . '
    ' . $form['html'] . self::mount( $ext ) . '

    ' . $back . '

    ' . '
    '; $order = $form['order']; } $answered = array(); foreach ( $step->answered as $seq => $is_final ) { $answered[ (string) $seq ] = (bool) $is_final; } return array( 'html' => '
    question && ! $final ? ' data-question="' . esc_attr( $step->question->uuid ) . '"' : '' ) . ' data-wp-run="callbacks.stepPaint" data-wp-init--step="callbacks.step">' . $html . '
    ', 'context' => array( 'sequential' => true, 'stepKey' => $key, 'seq' => $step->seq, 'total' => $step->total, 'question' => null !== $step->question && ! $final ? $step->question->uuid : '', 'remainingMs' => $remaining, 'limitMs' => $timed ? $step->limit_ms : 0, 'graceMs' => SequentialAttempts::grace_ms( $a->unit_id ), 'timeText' => $timed ? QuizMarkup::clock( (int) ceil( $remaining / 1000 ) ) : '', 'barWidth' => $timed ? self::bar_width( $remaining, $step->limit_ms ) : '0%', 'low' => $timed && $remaining <= 10000, 'announce' => '', 'timeUp' => false, 'canBack' => $step->can_back, 'last' => $step->seq >= $step->total, 'answered' => (object) $answered, 'feedback' => '' !== $feedback, 'order' => (object) $order, 'ext' => (object) $ext, ), 'key' => $key, ); } private static function mount( array $ext ): string { $keys = array(); foreach ( array_keys( $ext ) as $name ) { $name = sanitize_key( (string) $name ); if ( '' !== $name ) { $keys[] = $name; } } return '
    '; } private static function countdown( int $remaining_ms, int $limit_ms ): string { $width = self::bar_width( $remaining_ms, $limit_ms ); return '
    ' . '

    ' . '' . esc_html__( 'Time left for this question:', 'rooster-lms' ) . ' ' . '' . esc_html( QuizMarkup::clock( (int) ceil( $remaining_ms / 1000 ) ) ) . '

    ' . '' . '
    '; } private static function bar_width( int $remaining_ms, int $limit_ms ): string { if ( $limit_ms <= 0 ) { return '0%'; } return round( max( 0, min( 100, $remaining_ms * 100 / $limit_ms ) ), 1 ) . '%'; } public static function last_feedback( Attempt $a, string $lang ): string { if ( Delivery::SEQUENTIAL !== $a->delivery || Disclosure::AFTER_EACH_QUESTION !== $a->disclosure || $a->withheld() ) { return ''; } $answers = Services::attempt_repo()->answers( $a->id ); $last = self::question_at( $answers, count( $answers ) ); return null === $last ? '' : self::feedback( $a, $last, $answers, $lang ); } private static function question_at( array $answers, int $seq ): ?int { foreach ( $answers as $question_id => $answer ) { if ( (int) $answer['seq'] !== $seq || $seq <= 0 ) { continue; } $timed_out = 0 !== ( (int) $answer['flags'] & AttemptRepository::FLAG_TIMED_OUT ); return null !== $answer['answered_at'] || $timed_out ? (int) $question_id : null; } return null; } private static function feedback( Attempt $a, int $question_id, array $answers, string $lang ): string { $answer = $answers[ $question_id ] ?? null; if ( null === $answer || Disclosure::AFTER_EACH_QUESTION !== $a->disclosure ) { return ''; } $shown = AttemptRepository::shown( $answer ); $versions = Services::quiz_repo()->versions( array( (int) $answer['version_id'], $shown ) ); $version = $versions[ (int) $answer['version_id'] ] ?? null; $views = Services::quiz_repo()->view( $a->unit_id, '' !== $a->lang ? $a->lang : $lang, $a->token, array( $question_id ), array( $question_id => $shown ) )->questions; $q = $views[0] ?? null; if ( null === $version || null === $q ) { return ''; } $display = Services::quiz_repo()->displays( $a->unit_id, array( $question_id => $shown ), '' !== $a->lang ? $a->lang : $lang )[ $question_id ] ?? null; $answer['explanation'] = null === $display ? '' : $display->explanation; $timed_out = 0 !== ( (int) $answer['flags'] & AttemptRepository::FLAG_TIMED_OUT ); $max = (float) $version['points']; if ( $timed_out ) { $grade = new GradeResult( AnswerVerdict::WRONG, 0.0, $max ); } else { $marks = Services::grader()->grade( $version['key'], $max, (array) $answer['response'] ); $grade = new GradeResult( $answer['verdict'], $answer['points'], $max, $marks->gap_verdicts, $marks->option_verdicts ); } $verdict = $timed_out ? 'timed_out' : $grade->verdict->slug(); $title = match ( true ) { $timed_out => __( 'Time ran out.', 'rooster-lms' ), AnswerVerdict::CORRECT === $grade->verdict => __( 'Correct.', 'rooster-lms' ), AnswerVerdict::PARTIAL === $grade->verdict => __( 'Partly correct.', 'rooster-lms' ), default => __( 'Not correct.', 'rooster-lms' ), }; $heading = sprintf( /* translators: %s: question number. */ __( 'Question %s', 'rooster-lms' ), Format::number( (int) $answer['seq'] ) ); $icon = AnswerVerdict::CORRECT === $grade->verdict && ! $timed_out ? '✓' : ( AnswerVerdict::PARTIAL === $grade->verdict ? '◐' : '✗' ); return '
    ' . '

    ' . esc_html( $heading ) . ': ' . esc_html( $title ) . '

    ' . Services::review()->question( $q, $answer, $grade, ReviewMode::FULL, $version['key'] ) . '
    '; } public static function when( string $utc ): string { return Format::date( $utc, get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); } }