array(), 'br' => array(), 'strong' => array(), 'b' => array(), 'em' => array(), 'i' => array(), 'ul' => array(), 'ol' => array(), 'li' => array(), 'a' => array( 'href' => true, 'title' => true, 'target' => true, 'rel' => true, ), ); public const EXCERPT_MAX = 2000; public static function excerpt_html( string $raw ): string { $html = trim( wp_kses( $raw, self::EXCERPT_TAGS ) ); if ( mb_strlen( $html ) > self::EXCERPT_MAX ) { $html = force_balance_tags( wp_kses( mb_substr( $html, 0, self::EXCERPT_MAX ), self::EXCERPT_TAGS ) ); } return $html; } public static function read( Unit $unit, string $lang, int $user_id ): array { $view = Strings::edit_language( $lang ); $home = (int) $unit->home_post_id; $posts = Services::posts()->titles( ObjectType::UNIT, array( $unit->id ), $view ); $shown = (int) ( $posts[ $unit->id ]['post_id'] ?? 0 ); $shown = $shown > 0 ? $shown : $home; $post = get_post( $shown ); $session = UnitType::LIVE === $unit->type ? Kinds::lesson_session( Services::live_session_repo()->for_unit( $unit->id ) ) : null; $kind = Kinds::of( $unit, $session ); $header = self::header( $unit, $kind, $post, $home, $lang, $user_id ); $courses = Services::curriculum_repo()->course_ids_for_units( array( $unit->id ) ); $data = array( 'unit' => $header, 'courses' => array_values( array_map( 'intval', $courses ) ), 'form' => self::form( $unit, $kind, $session, $user_id ), 'files' => self::files( $unit->id, $view, $kind ), 'qa' => self::qa( $unit->id, (int) ( $courses[0] ?? 0 ) ), ); $data = apply_filters( 'rooster_lms_course_builder_unit_data', $data, $unit, $kind, $lang, $user_id ); $data = is_array( $data ) ? $data : array(); $data['rev'] = Rev::of( $data ); return $data; } private static function header( Unit $unit, string $kind, ?\WP_Post $post, int $home, string $lang, int $user_id ): array { $title = null === $post ? '' : (string) $post->post_title; $excerpt = null === $post ? '' : (string) $post->post_excerpt; $default = Strings::writes_stored( $lang ); return array( 'id' => $unit->id, 'type' => strtolower( $unit->type->name ), 'kind' => $kind, 'post_id' => $home, 'shown_post' => null === $post ? $home : $post->ID, 'translated' => $default || ( null !== $post && $post->ID !== $home ), 'title' => self::plain_title( $title ), 'excerpt' => $excerpt, 'status' => null === $post ? 'draft' : $post->post_status, 'can_publish' => null !== $post && user_can( $user_id, 'publish_post', $post->ID ), 'edit_url' => null === $post ? '' : (string) get_edit_post_link( $post->ID, 'raw' ), 'view_url' => null === $post ? '' : (string) get_permalink( $post->ID ), 'elementor' => null !== $post && defined( 'ELEMENTOR_VERSION' ) ? add_query_arg( array( 'post' => $post->ID, 'action' => 'elementor', ), admin_url( 'post.php' ) ) : '', 'rev' => self::header_rev( $title, $excerpt, null === $post ? 'draft' : $post->post_status ), ); } public static function header_rev( string $title, string $excerpt, string $status ): string { return Rev::of( array( $title, $excerpt, $status ) ); } public static function plain_title( string $raw ): string { return trim( html_entity_decode( wp_strip_all_tags( $raw ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); } private static function form( Unit $unit, string $kind, ?Session $session, int $user_id ): array { $fields = Services::unit_repo()->lesson_fields( array( $unit->id ) )[ $unit->id ] ?? $unit; $out = array( 'duration_sec' => $fields->duration_sec, 'duration_label' => $fields->duration_label, ); switch ( $kind ) { case 'video': case 'audio': case 'pdf': $media = Services::media_repo()->for_owner( MediaOwner::UNIT, $unit->id, MediaRole::PRIMARY ); $out['media'] = null === $media ? null : self::media( $media ); break; case 'live': case 'stream': $out['session'] = null === $session ? null : self::session( $session ); $out['picker'] = Kinds::picker( $user_id ); $out['zone'] = \RoosterLMS\Live\LiveSettings::default_zone(); break; case 'quiz': $out['quiz'] = self::quiz( $unit->id ); break; case 'assignment': $out['assignment'] = Services::assignments()->settings( $unit->id ); break; } return $out; } public static function quiz( int $unit_id ): array { $s = Services::quizzes()->settings( $unit_id ); return array( 'settings' => self::quiz_settings( $s ), 'questions' => count( Services::quiz_repo()->questions( $unit_id ) ), 'rev' => Rev::of( self::quiz_settings( $s ) ), ); } public static function quiz_settings( QuizSettings $s ): array { return array( 'mode' => $s->mode->slug(), 'pass_percent' => $s->pass_percent, 'max_attempts' => $s->max_attempts, 'time_limit_sec' => $s->time_limit_sec, 'shuffle_questions' => $s->shuffle_questions, 'shuffle_options' => $s->shuffle_options, 'review' => $s->review->value, 'retake_after_pass' => $s->retake_after_pass, 'require_all' => $s->require_all, 'widgets' => $s->widgets, 'h5p_counts' => $s->h5p_counts, ); } public static function session( Session $s ): array { return array( 'id' => $s->id, 'provider' => $s->provider->slug(), 'state' => $s->state->slug(), 'timezone' => $s->timezone, 'scheduled' => $s->is_scheduled(), 'starts_local' => null === $s->starts_local ? '' : substr( $s->starts_local, 0, 16 ), 'duration_min' => $s->duration_min, 'rrule' => $s->rrule, 'recurrence_text' => \RoosterLMS\Live\Recurrence::summary( $s->rrule, $s->timezone ), 'join_before_min' => $s->join_before_min, 'has_join' => '' !== $s->join_url, 'has_stream' => '' !== $s->stream_url_sealed, 'has_studio' => '' !== $s->studio_url_sealed, 'page_url' => \RoosterLMS\Live\JoinRoute::context_url( $s ), ); } public static function media( Media $media ): array { $file = static function ( ?int $id ): ?array { if ( null === $id || $id <= 0 ) { return null; } return array( 'id' => $id, 'url' => (string) wp_get_attachment_url( $id ), 'title' => (string) get_post_field( 'post_title', $id, 'raw' ), 'mime' => (string) get_post_mime_type( $id ), ); }; $ids = array_values( array_filter( array( (int) $media->attachment_id, (int) $media->poster_id ) ) ); if ( array() !== $ids ) { _prime_post_caches( $ids, false, false ); } return array( 'id' => $media->id, 'kind' => $media->kind->slug(), 'source' => $media->source->slug(), 'attachment_id' => $media->attachment_id, 'file' => $file( $media->attachment_id ), 'url' => $media->url, 'embed' => $media->embed, 'ref' => $media->ref, 'poster_id' => $media->poster_id, 'poster' => $file( $media->poster_id ), 'duration_sec' => $media->duration_sec, 'pages' => $media->pages, 'required_percent' => $media->required_percent, 'flags' => $media->flags, 'usable' => Services::media()->usable( $media ), 'tracks' => array_map( array( \RoosterLMS\Media\MediaController::class, 'track_shape' ), Services::media_repo()->tracks( $media->id ) ), ); } public static function files( int $unit_id, string $view, string $kind = '' ): array { $list = Services::materials()->for_unit( $unit_id ); $titles = array() === $list ? array() : Services::materials()->titles( $list, $view ); $ids = array_values( array_filter( array_map( static fn( Material $m ): int => $m->attachment_id, $list ) ) ); if ( array() !== $ids ) { _prime_post_caches( $ids, false, false ); } $items = array_map( static fn( Material $m ): array => array( 'id' => $m->id, 'title' => (string) ( $titles[ $m->id ] ?? $m->title ), 'stored' => $m->title, 'file' => $m->is_file(), 'attachment_id' => $m->attachment_id, 'filename' => $m->is_file() ? wp_basename( (string) get_attached_file( $m->attachment_id ) ) : '', 'url' => $m->is_file() ? '' : $m->url, 'release' => $m->release->slug(), ), $list ); return array( 'items' => $items, 'releases' => in_array( $kind, array( 'live', 'stream' ), true ), 'rev' => self::files_rev( $list ), ); } public static function files_rev( array $materials ): string { return Rev::of( array_map( static fn( Material $m ): array => array( $m->id, $m->title, $m->release->value ), $materials ) ); } private static function qa( int $unit_id, int $course_id ): array { $by = array(); foreach ( Db::rows( 'SELECT state, COUNT(*) AS n FROM %i WHERE unit_id = %d GROUP BY state', Db::table( 'discussions' ), $unit_id ) as $row ) { $by[ (int) $row['state'] ] = (int) $row['n']; } $counts = array(); foreach ( DiscussionState::cases() as $state ) { $counts[ $state->slug() ] = $by[ $state->value ] ?? 0; } return array( 'course_id' => $course_id, 'counts' => $counts, ); } public static function write_header( Unit $unit, array $patch, string $lang, string $rev, int $user_id, ?array &$stale = null ): array { $stale = null; $home = (int) $unit->home_post_id; $shown = (int) ( Services::posts()->titles( ObjectType::UNIT, array( $unit->id ), Strings::edit_language( $lang ) )[ $unit->id ]['post_id'] ?? 0 ); $post_id = $shown > 0 ? $shown : $home; if ( ! Strings::writes_stored( $lang ) && $post_id === $home && Names::default_language() !== Strings::edit_language( $lang ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'not_translated' ) ); } $post = get_post( $post_id ); if ( null === $post || ! user_can( $user_id, 'edit_post', $post_id ) ) { DomainError::raise( ErrorCode::FORBIDDEN ); } $now = self::header_values( $post ); if ( Rev::is_stale( $rev, $now['rev'] ) ) { $stale = $now; return $now; } $update = array( 'ID' => $post_id ); if ( isset( $patch['title'] ) ) { $title = trim( sanitize_text_field( (string) $patch['title'] ) ); if ( '' === $title ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'title' ) ); } $update['post_title'] = mb_substr( $title, 0, 200 ); } if ( isset( $patch['excerpt'] ) ) { $update['post_excerpt'] = self::excerpt_html( (string) $patch['excerpt'] ); } if ( isset( $patch['status'] ) && $patch['status'] !== $post->post_status ) { $status = (string) $patch['status']; if ( 'publish' === $status && ! user_can( $user_id, 'publish_post', $post_id ) ) { $status = 'pending'; } $update['post_status'] = $status; } $kses = isset( $update['post_excerpt'] ) && false !== has_filter( 'excerpt_save_pre', 'wp_filter_kses' ); if ( $kses ) { remove_filter( 'excerpt_save_pre', 'wp_filter_kses' ); } if ( count( $update ) > 1 ) { try { $done = wp_update_post( wp_slash( $update ), true ); } finally { if ( $kses ) { add_filter( 'excerpt_save_pre', 'wp_filter_kses' ); } } if ( $done instanceof \WP_Error ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'post' ) ); } clean_post_cache( $post_id ); } return self::header_values( get_post( $post_id ) ?? $post ); } private static function header_values( \WP_Post $post ): array { return array( 'title' => self::plain_title( (string) $post->post_title ), 'excerpt' => (string) $post->post_excerpt, 'status' => (string) $post->post_status, 'rev' => self::header_rev( (string) $post->post_title, (string) $post->post_excerpt, (string) $post->post_status ), ); } public static function item_impact( int $course_id, int $item_id, string $change ): array { $item = Services::curriculum_repo()->items( array( $item_id ) )[ $item_id ] ?? null; if ( null === $item || $item->course_id !== $course_id || \RoosterLMS\Content\ItemState::ACTIVE !== $item->state ) { DomainError::raise( ErrorCode::NOT_FOUND ); } $impact = \RoosterLMS\Admin\Lessons\LessonPlacements::impact( $item_id ); $open = max( 0, $impact['course_learners'] - $impact['completed'] ); $completes = $impact['required'] && $open > 0; $asks = 'optional' === $change ? $completes : ( $impact['learners'] > 0 || $completes ); $parts = array(); if ( $asks ) { $title = (string) ( Services::posts()->titles( ObjectType::UNIT, array( $item->unit_id ) )[ $item->unit_id ]['title'] ?? '' ); $course = (string) ( Services::posts()->titles( ObjectType::COURSE, array( $course_id ) )[ $course_id ]['title'] ?? '' ); if ( 'optional' === $change ) { $parts[] = sprintf( /* translators: 1: learners who have not completed the item, 2: the course's learners, 3: item title. */ _n( '%1$s of the course’s %2$s learners has not completed “%3$s” yet.', '%1$s of the course’s %2$s learners have not completed “%3$s” yet.', $open, 'rooster-lms' ), \RoosterLMS\I18n\Format::number( $open ), \RoosterLMS\I18n\Format::number( (int) $impact['course_learners'] ), $title ); $parts[] = __( 'Once it is optional, the course’s progress is recalculated: learners who have completed every other required item complete the course.', 'rooster-lms' ); } else { $parts[] = \RoosterLMS\Admin\Lessons\LessonsController::impact_text( $impact, $title, $course, '' ); } if ( $completes ) { $parts[] = __( 'Undo puts it back, but the course completions the recalculation gives meanwhile stay, with their certificates, notices and points.', 'rooster-lms' ); } } return $impact + array( 'not_completed' => $open, 'asks' => $asks, 'message' => implode( ' ', $parts ), ); } public static function write_item( int $course_id, int $item_id, array $patch, string $rev, Actor $actor, ?array &$stale = null ): array { $stale = null; $repo = Services::curriculum_repo(); $item = $repo->items( array( $item_id ) )[ $item_id ] ?? null; if ( null === $item || $item->course_id !== $course_id ) { DomainError::raise( ErrorCode::NOT_FOUND ); } $now = self::item_values( $item ); if ( Rev::is_stale( $rev, $now['flags_rev'] ) ) { $stale = $now; return $now; } $curriculum = Services::curriculum(); if ( array_key_exists( 'required', $patch ) || array_key_exists( 'preview', $patch ) ) { $curriculum->set_item_flags( $item_id, array_key_exists( 'required', $patch ) ? (bool) $patch['required'] : null, array_key_exists( 'preview', $patch ) ? (bool) $patch['preview'] : null, $actor ); } if ( array_key_exists( 'unlock', $patch ) ) { $rule = CurriculumData::rule_of( (string) $patch['unlock'] ) ?? DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'unlock' ) ); $days = \RoosterLMS\Content\UnlockRule::DAYS_AFTER_ENROLMENT === $rule ? (int) ( $patch['days'] ?? $item->rules->days ?? 0 ) : null; $at = \RoosterLMS\Content\UnlockRule::ON_DATE === $rule ? (string) ( $patch['at'] ?? $item->rules->at ?? '' ) : null; $curriculum->set_item_rules( $item_id, new \RoosterLMS\Content\ItemRules( $rule, $days, '' === $at ? null : $at, $item->rules->quiz_completion ), $actor ); } $repo->forget(); return self::item_values( $repo->items( array( $item_id ) )[ $item_id ] ?? $item ); } private static function item_values( $item ): array { $row = array( 'id' => $item->id, 'required' => $item->is_required, 'preview' => $item->is_preview, ) + CurriculumData::availability( $item ); $row['flags_rev'] = CurriculumData::flags_rev( $row ); return $row; } public static function write_quiz( int $unit_id, array $in, string $rev, Actor $actor, ?array &$stale = null ): array { $stale = null; $now = self::quiz( $unit_id ); if ( Rev::is_stale( $rev, $now['rev'] ) ) { $stale = $now; return $now; } $s = Services::quizzes()->settings( $unit_id ); $mode = \RoosterLMS\Assessment\QuizMode::from_slug( (string) ( $in['mode'] ?? $s->mode->slug() ) ); if ( null === $mode || \RoosterLMS\Assessment\QuizMode::BANK === $mode ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'mode' ) ); } $review = \RoosterLMS\Assessment\ReviewMode::tryFrom( (string) ( $in['review'] ?? $s->review->value ) ) ?? $s->review; $num = static fn( string $key, ?int $current, int $none ): ?int => array_key_exists( $key, $in ) ? ( (int) $in[ $key ] === $none ? null : (int) $in[ $key ] ) : $current; $next = new QuizSettings( $num( 'pass_percent', $s->pass_percent, -1 ), $num( 'max_attempts', $s->max_attempts, 0 ), $num( 'time_limit_sec', $s->time_limit_sec, 0 ), (bool) ( $in['shuffle_questions'] ?? $s->shuffle_questions ), (bool) ( $in['shuffle_options'] ?? $s->shuffle_options ), $review, (bool) ( $in['retake_after_pass'] ?? $s->retake_after_pass ), $mode, $s->policy, (bool) ( $in['require_all'] ?? $s->require_all ), $s->grade_scale_id, (bool) ( $in['widgets'] ?? $s->widgets ), (bool) ( $in['h5p_counts'] ?? $s->h5p_counts ) ); Services::quizzes()->update_settings( $unit_id, $next, $actor ); Services::quizzes()->forget(); Services::quiz_repo()->forget( $unit_id ); return self::quiz( $unit_id ); } public static function write_files( int $unit_id, string $op, array $in, string $lang, string $rev, Actor $actor, ?array &$stale = null ): array { $stale = null; $service = Services::materials(); $view = Strings::edit_language( $lang ); $kind = (string) ( $in['kind'] ?? '' ); if ( Rev::is_stale( $rev, self::files_rev( $service->for_unit( $unit_id ) ) ) ) { $stale = self::files( $unit_id, $view, $kind ); return $stale; } $id = (int) ( $in['id'] ?? 0 ); if ( 'add' !== $op && 'order' !== $op ) { $material = $service->get( $id ); if ( null === $material || $material->unit_id !== $unit_id ) { DomainError::raise( ErrorCode::NOT_FOUND ); } } $release = MaterialRelease::from_slug( (string) ( $in['release'] ?? '' ) ); switch ( $op ) { case 'add': $made = $service->add( $unit_id, (int) ( $in['attachment_id'] ?? 0 ), (string) ( $in['url'] ?? '' ), (string) ( $in['title'] ?? '' ), $release ?? MaterialRelease::BEFORE, $actor ); $id = $made->id; break; case 'update': $title = array_key_exists( 'title', $in ) ? trim( sanitize_text_field( (string) $in['title'] ) ) : null; if ( null !== $title && ! Strings::writes_stored( $lang ) ) { Services::strings()->save_translation( \RoosterLMS\I18n\TextObject::MATERIAL, $id, $material->title, $view, $title, $actor ); $title = null; } if ( null !== $title || null !== $release ) { $service->update( $id, $title ?? $material->title, $release ?? $material->release, $actor ); } break; case 'remove': $service->remove( $id, $actor ); break; case 'order': $service->reorder( $unit_id, array_map( 'intval', (array) ( $in['ids'] ?? array() ) ), $actor ); break; default: DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'op' ) ); } $service->forget(); $out = self::files( $unit_id, $view, $kind ); if ( 'add' === $op || 'update' === $op ) { foreach ( $out['items'] as $file ) { if ( $file['id'] === $id ) { $out['file'] = $file; } } } return $out; } }