isset( $attributes['lessonPost'] ) ? max( 0, (int) $attributes['lessonPost'] ) : 0 ); } public static function render( array $attributes, string $content = '', ?\WP_Block $block = null ): string { unset( $content ); $item_id = Context::item_id( self::attributes( $attributes ), $block ); $item = $item_id > 0 ? ( Services::curriculum_repo()->items( array( $item_id ) )[ $item_id ] ?? null ) : null; if ( null === $item ) { return ''; } LessonExtras::rendered( self::NAME, $item->id ); $unit = Services::unit_repo()->get_many( array( $item->unit_id ) )[ $item->unit_id ] ?? null; if ( null === $unit || UnitType::ASSIGNMENT !== $unit->type ) { return ''; } $user = get_current_user_id(); $settings = Services::assignments()->settings( $item->unit_id ); if ( $user <= 0 ) { return self::wrap( $block, 'guest', self::facts( $settings, -2 ) . Markup::login_prompt( __( 'Log in to hand in this assignment.', 'rooster-lms' ) ) ); } $open = Services::gate()->can_open_item( $user, $item->id ); if ( $open->allowed && $open->reason->is_staff() ) { return self::wrap( $block, 'staff', self::facts( $settings, -2 ) . Ui::notice( __( 'Learners hand in their answers here. You grade them in the grading queue.', 'rooster-lms' ), 'info' ) ); } $decision = Services::gate()->can_complete_item( $user, $item->id ); if ( $decision->allowed || AccessReason::CONTENT_CONTROLLED !== $decision->reason ) { return ''; } $state = Services::assignments()->state( $user, $item->id ); return self::learner( $item, $block, $settings, $state ); } private static function learner( Item $item, ?\WP_Block $block, array $settings, array $state ): string { $tries = $state['tries']; $latest = array() === $tries ? null : $tries[ count( $tries ) - 1 ]; $status = null === $latest ? null : ( self::status_of( (string) $latest['status'] ) ?? SubmissionStatus::DRAFT ); $body = self::facts( $settings, $state['left'] ); $config = array( 'item' => $item->id, 'i18n' => self::texts(), ); $mode = 'start'; if ( null === $latest ) { $body .= self::start_button( $settings, false ); } elseif ( SubmissionStatus::DRAFT === $status ) { $mode = 'draft'; $config = array_merge( $config, self::draft_config( $latest ) ); $body .= self::draft( $latest ); } elseif ( SubmissionStatus::SUBMITTED === $status ) { $mode = 'submitted'; /* translators: %s: date and time. */ $body .= Ui::notice( sprintf( __( 'Handed in on %s. Your answer is waiting for grading.', 'rooster-lms' ), Front::when( $latest['submitted_at'] ?? null ) ), 'info' ) . self::answer( $latest ); } else { $mode = 'graded'; $body .= self::result( $latest ); if ( SubmissionStatus::FAILED === $status && 0 !== $state['left'] ) { $body .= self::start_button( $settings, true ); } elseif ( SubmissionStatus::FAILED === $status ) { $body .= Ui::notice( __( 'You have no tries left.', 'rooster-lms' ), 'warning' ); } } if ( count( $tries ) > 1 ) { $body .= self::history( array_slice( $tries, 0, -1 ) ); } if ( in_array( $mode, array( 'start', 'draft', 'graded' ), true ) ) { Front::enqueue( self::NAME ); } return self::wrap( $block, $mode, $body, array( 'data-rl-assignment' => (string) wp_json_encode( Front::config( $config ) ) ) ); } private static function draft_config( array $try ): array { $remaining = -1; if ( null !== $try['deadline_at'] ) { $deadline = strtotime( $try['deadline_at'] . ' UTC' ); $remaining = false === $deadline ? 0 : max( 0, $deadline - Clock::timestamp() ); } return array( 'submission' => (int) $try['id'], 'files' => $try['files'], 'remaining' => $remaining, 'maxFiles' => (int) Settings::get( 'assignment_max_files' ), 'maxFileMb' => (int) Settings::get( 'assignment_max_file_mb' ), 'maxMediaMb' => (int) Settings::get( 'assignment_media_max_mb' ), 'audio' => (bool) Settings::get( 'assignment_audio' ), 'video' => (bool) Settings::get( 'assignment_video' ), 'exts' => AssignmentService::upload_extensions( true ), ); } private static function facts( array $settings, int $left ): string { $items = array(); if ( -2 === $left ) { $items[] = null === $settings['tries'] ? __( 'Unlimited tries', 'rooster-lms' ) /* translators: %s: number of tries. */ : sprintf( _n( '%s try', '%s tries', (int) $settings['tries'], 'rooster-lms' ), Format::number( (int) $settings['tries'] ) ); } elseif ( -1 === $left ) { $items[] = __( 'Unlimited tries', 'rooster-lms' ); } else { /* translators: %s: number of tries left. */ $items[] = sprintf( _n( '%s try left', '%s tries left', $left, 'rooster-lms' ), Format::number( $left ) ); } if ( null !== $settings['time_limit_sec'] ) { /* translators: %s: a duration such as "1 hour 30 minutes". */ $items[] = sprintf( __( 'Time limit: %s', 'rooster-lms' ), self::duration( (int) $settings['time_limit_sec'] ) ); } if ( null !== $settings['pass_percent'] ) { /* translators: %s: a percentage. */ $items[] = sprintf( __( 'Pass mark: %s', 'rooster-lms' ), Format::percent( (int) $settings['pass_percent'] ) ); } $html = ''; } private static function start_button( array $settings, bool $again ): string { $warn = null === $settings['time_limit_sec'] ? '' : '

' /* translators: %s: a duration. */ . esc_html( sprintf( __( 'When you start, you have %s to hand in your answer.', 'rooster-lms' ), self::duration( (int) $settings['time_limit_sec'] ) ) ) . '

'; return $warn . '

' . Ui::button( $again ? __( 'Try again', 'rooster-lms' ) : __( 'Start the assignment', 'rooster-lms' ), 'primary', array( 'data-rl-asg' => 'start' ) ) . '

' . ''; } private static function draft( array $try ): string { $id = 'rl-asg-' . (int) $try['id']; $html = ''; if ( null !== $try['deadline_at'] ) { $html .= '

' . esc_html__( 'Time left:', 'rooster-lms' ) . ' ' . esc_html( self::clock( (int) max( 0, (int) strtotime( $try['deadline_at'] . ' UTC' ) - Clock::timestamp() ) ) ) . '

'; } $html .= '
'; $html .= '

' . '

'; $exts = AssignmentService::upload_extensions( true ); $html .= '
' . esc_html__( 'Files', 'rooster-lms' ) . '' . '' . '

' . '

' /* translators: 1: file types, 2: size in MB, 3: number of files. */ . '

' . esc_html( sprintf( __( 'Allowed types: %1$s. Up to %2$s MB a file, %3$s files.', 'rooster-lms' ), implode( ', ', (array) Settings::get( 'assignment_file_types' ) ), Format::number( (int) Settings::get( 'assignment_max_file_mb' ) ), Format::number( (int) Settings::get( 'assignment_max_files' ) ) ) ) . '

'; foreach ( array( 'audio' => array( (bool) Settings::get( 'assignment_audio' ), __( 'Record an audio answer', 'rooster-lms' ) ), 'video' => array( (bool) Settings::get( 'assignment_video' ), __( 'Record a video answer', 'rooster-lms' ) ), ) as $kind => $spec ) { if ( ! $spec[0] ) { continue; } $html .= '
' . Ui::button( $spec[1], 'outline', array( 'size' => 'sm', 'data-rl-asg-rec' => 'start' ) ) . Ui::button( __( 'Stop recording', 'rooster-lms' ), 'destructive', array( 'size' => 'sm', 'data-rl-asg-rec' => 'stop', 'hidden' => true ) ) . ' ' . ( 'video' === $kind ? '' : '' ) . '' . '
'; } $html .= '
'; $html .= '

' . Ui::button( __( 'Save draft', 'rooster-lms' ), 'outline', array( 'data-rl-asg' => 'save' ) ) . ' ' . Ui::button( __( 'Hand in', 'rooster-lms' ), 'primary', array( 'type' => 'submit' ) ) . '

' . '
'; return $html; } private static function result( array $try ): string { $passed = 'passed' === $try['status']; $html = Ui::notice( $passed ? __( 'Your assignment has passed.', 'rooster-lms' ) : __( 'Your assignment did not pass.', 'rooster-lms' ), $passed ? 'success' : 'warning' ); if ( null !== $try['score'] ) { /* translators: %s: a percentage. */ $html .= '

' . esc_html( sprintf( __( 'Score: %s', 'rooster-lms' ), Format::percent( (int) round( (float) $try['score'] ) ) ) ) . '

'; } if ( '' !== trim( (string) $try['feedback'] ) ) { $html .= '

' . esc_html__( 'Feedback', 'rooster-lms' ) . '

' . wpautop( esc_html( (string) $try['feedback'] ) ) . '
'; } if ( array() !== $try['staff_files'] ) { $html .= '

' . esc_html__( 'Files from your trainer', 'rooster-lms' ) . '

'; } return $html . self::answer( $try ); } private static function answer( array $try ): string { $html = '
' . esc_html__( 'Your answer', 'rooster-lms' ) . '' . wp_kses_post( wpautop( (string) $try['body'] ) ); if ( array() !== $try['files'] ) { $html .= ''; } return $html . '
'; } private static function history( array $tries ): string { $html = '
' . esc_html__( 'Earlier tries', 'rooster-lms' ) . '
    '; foreach ( $tries as $t ) { $status = self::status_of( (string) $t['status'] ); /* translators: 1: try number, 2: the try's state. */ $html .= '
  1. ' . esc_html( sprintf( __( 'Try %1$s: %2$s', 'rooster-lms' ), Format::number( (int) $t['try_no'] ), null === $status ? '' : $status->label() ) ); if ( '' !== trim( (string) $t['feedback'] ) ) { $html .= ''; } $html .= '
  2. '; } return $html . '
'; } public static function file_items( int $submission, array $files, bool $removable ): string { $ids = array_map( static fn( array $f ): int => (int) $f['id'], $files ); if ( array() !== $ids ) { _prime_post_caches( $ids, false, false ); } $html = ''; foreach ( $files as $f ) { $name = self::file_name( (int) $f['id'] ); $html .= '
  • ' . esc_html( $name ) . '' . ( $removable ? ' ' . Ui::button( __( 'Remove', 'rooster-lms' ), 'ghost', array( 'size' => 'sm', 'data-rl-asg-remove' => (string) $f['id'], 'aria-label' => sprintf( /* translators: %s: file name. */ __( 'Remove %s', 'rooster-lms' ), $name ) ) ) : '' ) . '
  • '; } return $html; } public static function file_name( int $attachment ): string { $file = (string) get_attached_file( $attachment, true ); $ext = strtolower( (string) pathinfo( $file, PATHINFO_EXTENSION ) ); $title = trim( (string) get_post_field( 'post_title', $attachment, 'raw' ) ); $name = '' !== $title ? $title . ( '' !== $ext ? '.' . $ext : '' ) : ( '' !== $file ? wp_basename( $file ) : '' ); return '' !== $name ? $name : __( 'File', 'rooster-lms' ); } public static function file_url( int $submission, int $attachment ): string { return \RoosterLMS\Live\Lesson\Links::submission_file( $submission, $attachment ); } private static function status_of( string $slug ): ?SubmissionStatus { foreach ( SubmissionStatus::cases() as $case ) { if ( $case->slug() === $slug ) { return $case; } } return null; } public static function duration( int $sec ): string { $h = intdiv( $sec, 3600 ); $m = intdiv( $sec % 3600, 60 ); $parts = array(); if ( $h > 0 ) { /* translators: %s: number of hours. */ $parts[] = sprintf( _n( '%s hour', '%s hours', $h, 'rooster-lms' ), Format::number( $h ) ); } if ( $m > 0 || array() === $parts ) { /* translators: %s: number of minutes. */ $parts[] = sprintf( _n( '%s minute', '%s minutes', max( 1, $m ), 'rooster-lms' ), Format::number( max( 1, $m ) ) ); } return implode( ' ', $parts ); } public static function clock( int $sec ): string { $h = intdiv( $sec, 3600 ); $m = intdiv( $sec % 3600, 60 ); $s = $sec % 60; return $h > 0 ? sprintf( '%d:%02d:%02d', $h, $m, $s ) : sprintf( '%d:%02d', $m, $s ); } private static function wrap( ?\WP_Block $block, string $state, string $body, array $extra = array() ): string { return '
    $state, 'aria-label' => __( 'Assignment', 'rooster-lms' ) ) + $extra ) . '>' . $body . '
    '; } public static function texts(): array { return array( 'confirm' => __( 'Hand in your answer? You cannot change it afterwards.', 'rooster-lms' ), 'handIn' => __( 'Hand in', 'rooster-lms' ), 'keepEdit' => __( 'Keep editing', 'rooster-lms' ), 'saved' => __( 'Draft saved.', 'rooster-lms' ), 'empty' => __( 'Write an answer or add a file before you hand in.', 'rooster-lms' ), 'uploading' => __( 'Uploading…', 'rooster-lms' ), 'uploaded' => __( 'File added.', 'rooster-lms' ), 'tooBig' => __( 'This file is too large.', 'rooster-lms' ), 'badType' => __( 'This type of file is not allowed.', 'rooster-lms' ), 'tooMany' => __( 'You cannot add more files.', 'rooster-lms' ), 'removed' => __( 'File removed.', 'rooster-lms' ), 'recording' => __( 'Recording…', 'rooster-lms' ), 'noDevice' => __( 'The microphone or camera could not be used. Choose a file instead.', 'rooster-lms' ), 'timeUp' => __( 'Time is up: your answer is being handed in.', 'rooster-lms' ), 'failed' => __( 'Something went wrong. Please try again.', 'rooster-lms' ), 'remove' => __( 'Remove', 'rooster-lms' ), 'recordName' => __( 'Recording', 'rooster-lms' ), ); } }