'#1e63ce', 'content' => '#75beff', 'assessment' => '#8fe03a', 'orgs' => '#ff4d4d', 'progress' => '#ffde00', 'packages' => '#b794f6', 'tools' => '#a1a1aa', ); /** * The names the LMS's template slots (contract C3) use for its entities, where they differ from the icon map's * keys: slot name => icon key. */ private const SLOT_ALIASES = array( 'course-archive' => 'course', 'account' => 'account_page', 'org-portal' => 'portal', 'register' => 'registration_form', 'certificate-verify' => 'certificate_verification', ); /** * Whether the LMS and its icon map are loaded. */ public static function present(): bool { return defined( 'ROOSTER_LMS_VERSION' ) && class_exists( self::LMS_ICONS ) && defined( self::LMS_ICONS . '::KEYS' ); } /** * Whether the LMS's registry is loaded (the admin: its Home module). */ public static function registry_present(): bool { return self::present() && class_exists( self::LMS_REGISTRY ) && class_exists( self::LMS_SECTIONS ) && class_exists( self::LMS_ADMIN ); } /** * Hooks the bridges: the entity icons and the LMS's capability word. */ public static function register(): void { if ( ! self::present() ) { return; } add_filter( 'rooster/admin/entity_icons', array( self::class, 'entity_icons' ) ); add_filter( 'rooster/admin/capability', array( self::class, 'capability' ), 10, 2 ); } /** * Filter rooster/admin/capability: `grading` is the LMS's (a grading scope of the current user). * * @param bool|null $can The answer so far. * @param string $spec The spec. * @return bool|null */ public static function capability( $can, string $spec ) { if ( 'grading' === $spec && self::registry_present() ) { return (bool) call_user_func( array( self::LMS_REGISTRY, 'can' ), array( 'caps' => array( 'grading' ) ) ); } return $can; } /** * Filter rooster/admin/entity_icons: `rooster-lms/` => Lucide name from the LMS's map, plus its slots' * names. * * @param array $icons Icons so far. * @param array|null $keys The LMS's map (null = read from the LMS; tests pass one). * @return array */ public static function entity_icons( array $icons, ?array $keys = null ): array { if ( null === $keys ) { if ( ! self::present() ) { return $icons; } $keys = (array) constant( self::LMS_ICONS . '::KEYS' ); } foreach ( $keys as $entity => $name ) { if ( is_string( $entity ) && is_string( $name ) ) { $icons[ 'rooster-lms/' . str_replace( '_', '-', $entity ) ] = $name; } } foreach ( self::SLOT_ALIASES as $slot => $entity ) { if ( isset( $keys[ $entity ] ) ) { $icons[ 'rooster-lms/' . $slot ] = (string) $keys[ $entity ]; } } return $icons; } /** * The id table: an LMS id under the product's prefix ('' for none). * * @param string $old An id of the LMS's registry (also a pin of its ui_prefs table). */ public static function id_of( string $old ): string { $old = trim( $old ); return '' === $old || 1 !== preg_match( '/^[a-z0-9_]{1,40}$/', $old ) ? '' : self::PRODUCT . '/' . $old; } /** * The product (CONTRACT §2) from the LMS's own data: its sections in menu order with their colours, icons, groups * and stacks; its personas as audiences; its screens. * * @return array */ public static function product(): array { $lms = self::LMS_REGISTRY; $labels = (array) call_user_func( array( self::LMS_SECTIONS, 'labels' ) ); $groups = (array) call_user_func( array( $lms, 'groups' ) ); $stacks = (array) constant( $lms . '::STACKS' ); $icons = (array) constant( self::LMS_ICONS . '::SECTIONS' ); $sections = array(); foreach ( (array) constant( self::LMS_SECTIONS . '::KEYS' ) as $key ) { $key = (string) $key; $named = array(); foreach ( (array) ( $stacks[ $key ] ?? array() ) as $stack ) { foreach ( (array) $stack as $group ) { $named[ (string) $group ] = (string) ( $groups[ $group ] ?? $group ); } } $sections[ $key ] = array( 'label' => (string) ( $labels[ $key ] ?? $key ), 'colour' => self::COLOURS[ $key ] ?? '#a1a1aa', 'icon' => (string) ( $icons[ $key ] ?? 'layout-grid' ), 'groups' => $named, 'stacks' => (array) ( $stacks[ $key ] ?? array() ), ); } $personas = include dirname( ( new \ReflectionClass( $lms ) )->getFileName() ) . '/data/personas.php'; $audiences = array(); foreach ( is_array( $personas ) ? $personas : array() as $key => $persona ) { $audiences[ (string) $key ] = array( 'label' => (string) ( $persona['label'] ?? $key ), 'sentence' => (string) ( $persona['sentence'] ?? '' ), 'all' => ! empty( $persona['all'] ), ); } $pro = (bool) call_user_func( array( $lms, 'plugin_active' ), constant( $lms . '::PRO' ) ); $links = class_exists( self::LMS_CHROME ) ? (array) call_user_func( array( self::LMS_CHROME, 'links' ) ) : array(); return array( 'slug' => self::PRODUCT, 'name' => 'Rooster LMS', 'words' => array( 'Rooster', 'LMS' ), 'colour' => self::COLOUR, 'version' => defined( 'ROOSTER_LMS_VERSION' ) ? (string) ROOSTER_LMS_VERSION : '', 'strapline' => __( 'Courses, live sessions, quizzes, organisations and certificates: the academy on one site.', 'ai-eshop-optimizer' ), 'links' => $links, 'welcome' => class_exists( self::LMS_CHROME ) && method_exists( self::LMS_CHROME, 'welcome' ) ? (array) call_user_func( array( self::LMS_CHROME, 'welcome' ) ) : array(), // the LMS's words in the welcome's anatomy (0.21.0) 'tier' => array( 'label' => $pro ? _x( 'Pro', 'the tier pill', 'ai-eshop-optimizer' ) : _x( 'Free', 'the tier pill', 'ai-eshop-optimizer' ), 'on' => $pro, 'detail' => '', ), 'home' => array( 'parent' => (string) constant( self::LMS_ADMIN . '::PARENT' ), 'slug' => 'rooster-lms-home', 'capability' => 'read', ), 'sections' => $sections, 'audiences' => $audiences, 'screens' => self::screens(), ); } /** * The LMS's screens: its page slugs under the menu, and the list and taxonomy screens of its post types. * * @return array{pages: list, ids: list} */ private static function screens(): array { $pages = array(); $ids = array(); foreach ( self::rows() as $row ) { $slug = str_replace( '&', '&', (string) $row['slug'] ); if ( 1 === preg_match( '/^[a-z0-9_-]+\.php(\?|$)/i', $slug ) ) { $query = array(); parse_str( (string) wp_parse_url( $slug, PHP_URL_QUERY ), $query ); $file = (string) wp_parse_url( $slug, PHP_URL_PATH ); if ( 'edit.php' === $file && is_string( $query['post_type'] ?? null ) ) { $ids[ 'edit-' . $query['post_type'] ] = true; } elseif ( 'edit-tags.php' === $file && is_string( $query['taxonomy'] ?? null ) ) { $ids[ 'edit-' . $query['taxonomy'] ] = true; } } else { $pages[ $slug ] = true; } } return array( 'pages' => array_keys( $pages ), 'ids' => array_keys( $ids ), ); } /** * The LMS's expanded rows, every kind (id => row). * * @return array> */ private static function rows(): array { if ( ! self::registry_present() ) { return array(); } $lms = self::LMS_REGISTRY; return (array) call_user_func( array( $lms, 'entries' ), (array) constant( $lms . '::KINDS' ) ); } /** * The catalogue rows (CONTRACT §3) from the LMS's: the same ids under the prefix, kinds page, tab and card as * they are, a tab or card under its page (or its parent tab), the LMS's capability specs unchanged, its tier as * `available` and a badge, its icon keys resolved by the LMS's own map. * * @return list> */ public static function entries(): array { $lms = self::LMS_REGISTRY; $icons = self::LMS_ICONS; $out = array(); foreach ( self::rows() as $id => $row ) { if ( ! self::offered( $row ) ) { continue; } $out[] = self::map_row( (string) $id, $row, (string) call_user_func( array( $lms, 'page_url' ), $row ), (string) call_user_func( array( $icons, 'of_entry' ), $row ), (bool) call_user_func( array( $lms, 'plugin_active' ), (string) ( $row['plugin'] ?? '' ) ) ); } return self::rooted( $out ); } /** * Whether the LMS offers a row as a destination: a page always; a tab or card unless it says `pin` false, the LMS's * mark of a drill-down panel, an add-flow step or a "not found" state, which its own chrome kept out of the menus, * Home, the palette and the pins (ADMIN-CARDS-CONTRACT §4.3). The adapter keeps it out of the catalogue too * (MENU-AUDIT round 2: 265 such cards reached the menu bar, 46 of the 53 under SCORM, xAPI & AI not even on the * screen their link opens). Pure. * * @param array $row The LMS row. */ public static function offered( array $row ): bool { return 'page' === (string) ( $row['kind'] ?? 'page' ) || false !== ( $row['pin'] ?? true ); } /** * Rows whose parent is not among the rows stand on their own (the product's data names a page it does not * list; the registry would record a problem and do the same). * * @param list> $rows Rows. * @return list> */ public static function rooted( array $rows ): array { $ids = array_fill_keys( array_column( $rows, 'id' ), true ); foreach ( $rows as &$row ) { if ( '' !== $row['parent'] && ! isset( $ids[ $row['parent'] ] ) ) { $row['parent'] = ''; } } return $rows; } /** * One of the LMS's rows as a catalogue row (pure: the URL, the icon and the plugin's state come in). * * @param string $id The LMS id. * @param array $row The LMS row. * @param string $url Its URL (Registry::page_url()). * @param string $icon Its Lucide name (Icons::of_entry()). * @param bool $available Whether its plugin is active. * @return array */ public static function map_row( string $id, array $row, string $url, string $icon, bool $available ): array { $kind = (string) ( $row['kind'] ?? 'page' ); $parent = ''; if ( 'page' !== $kind ) { // A tab or card under its page; under its parent tab when it names one (the LMS's `parent` is the tab's // key: the tab's id is t__, Registry::tab_id()). $page = (string) ( $row['page'] ?? '' ); $parent = self::id_of( '' !== (string) ( $row['parent'] ?? '' ) ? 't_' . $page . '_' . (string) $row['parent'] : $page ); } return array( 'id' => self::id_of( $id ), 'kind' => $kind, 'product' => self::PRODUCT, 'section' => (string) ( $row['section'] ?? '' ), 'group' => (string) ( $row['group'] ?? '' ), 'title' => (string) ( $row['title'] ?? $id ), 'description' => (string) ( $row['desc'] ?? '' ), 'url' => $url, 'icon' => $icon, 'capabilities' => array_values( array_map( 'strval', (array) ( $row['caps'] ?? array() ) ) ), 'audiences' => array_values( array_map( 'strval', (array) ( $row['audiences'] ?? array() ) ) ), 'keywords' => array_values( array_filter( explode( ' ', (string) ( $row['keywords'] ?? '' ) ) ) ), 'pin' => ! empty( $row['pin'] ), // A card of a record tab (the last organisation's members, say) is a panel of one record: the palette and // the pins keep it, the menus and Home do not (MENU-AUDIT round 2: 60 of Organisations' 63 folded cards). 'listed' => ( ! isset( $row['listed'] ) || ! empty( $row['listed'] ) ) && ( 'card' !== $kind || '' === (string) ( $row['record'] ?? '' ) ), 'current' => ! isset( $row['current'] ) || ! empty( $row['current'] ), 'available' => $available, 'badge' => $available ? '' : ( 'rooster-lti' === (string) ( $row['plugin'] ?? '' ) ? 'LTI' : 'Pro' ), 'parent' => $parent, ); } /** * Moves a user's pins from the LMS's ui_prefs table into the one row, once: the result is the row's pins; the * table's row is deleted after. * * @param int $user_id User. * @return list */ public static function migrate_pins( int $user_id ): array { if ( $user_id <= 0 || ! self::registry_present() || ! class_exists( self::LMS_PREFS ) ) { return array(); } $old = call_user_func( array( self::LMS_PREFS, 'get' ), $user_id, 'pins', array() ); if ( ! is_array( $old ) || array() === $old ) { return array(); } $pins = Prefs::migrate( $user_id, self::PRODUCT, array_values( array_filter( $old, 'is_string' ) ), array( self::class, 'id_of' ) ); call_user_func( array( self::LMS_PREFS, 'delete' ), $user_id, 'pins' ); return $pins; } /** * Moves a user's chrome preferences from the LMS's ui_prefs table into the one row, once: the pins (migrate_pins()) * and the audiences (the "For:" choice, the same persona keys); the Home layout starts afresh (another shape). The * table's three rows are deleted after. Nothing when the table holds none of them. * * @param int $user_id User. */ public static function migrate_prefs( int $user_id ): void { if ( $user_id <= 0 || ! self::registry_present() || ! class_exists( self::LMS_PREFS ) ) { return; } self::migrate_pins( $user_id ); $audiences = call_user_func( array( self::LMS_PREFS, 'get' ), $user_id, 'audiences', array() ); if ( is_array( $audiences ) && array() !== $audiences ) { $registry = \Rooster\Admin\Library::registry(); $product = null !== $registry ? $registry->product( self::PRODUCT ) : null; $clean = null !== $product ? \Rooster\Admin\Personas::clean( $product, array_values( array_filter( $audiences, 'is_string' ) ) ) : array(); if ( array() !== $clean && array() === (array) Prefs::get( $user_id, Prefs::AUDIENCES, self::PRODUCT, array() ) ) { Prefs::set( $user_id, Prefs::AUDIENCES, $clean, self::PRODUCT ); } call_user_func( array( self::LMS_PREFS, 'delete' ), $user_id, 'audiences' ); } if ( null !== call_user_func( array( self::LMS_PREFS, 'get' ), $user_id, 'home_layout', null ) ) { call_user_func( array( self::LMS_PREFS, 'delete' ), $user_id, 'home_layout' ); } } /** * E's switch: registers the product, its rows and its entities' icons (the LMS calls it on * `rooster/admin/register` the day its own chrome goes). * * @param Registry $registry The registry. */ public static function register_product( Registry $registry ): void { if ( ! self::registry_present() ) { return; } $registry->register_product( self::product() ); $registry->register_entries( self::entries() ); $registry->register_entity_icons( self::PRODUCT, (array) constant( self::LMS_ICONS . '::KEYS' ) ); } }