ACADEMY_PLUGIN_SLUG, 'title' => __( 'Dashboard', 'academy' ), 'capability' => 'manage_options', ]; $menu[ ACADEMY_PLUGIN_SLUG . '-courses' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Courses', 'academy' ), 'capability' => 'manage_options', 'sub_items' => [ [ 'slug' => '', 'title' => __( 'All Courses', 'academy' ), ], [ 'slug' => 'category', 'title' => __( 'Category', 'academy' ), ], [ 'slug' => 'tags', 'title' => __( 'Tags', 'academy' ), ], ], ]; $menu[ ACADEMY_PLUGIN_SLUG . '-lessons' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Lessons', 'academy' ), 'capability' => 'manage_options', ]; if ( self::get_addon_active_status( 'quizzes' ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-quizzes' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Quizzes', 'academy' ), 'capability' => 'manage_options', // The page's route tabs (not a submenu). Kept apart from // `sub_items` so a group can still use the page as its link; // global search indexes both. 'tab_items' => [ [ 'slug' => '', 'title' => __( 'All Quizzes', 'academy' ), ], [ 'slug' => 'questions', 'title' => __( 'Questions', 'academy' ), ], [ 'slug' => 'attempts', 'title' => __( 'Quiz Attempts', 'academy' ), ] ] ]; }//end if if ( self::is_active_academy_pro() ) { if ( self::get_addon_active_status( 'meeting' ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-meeting' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Meeting', 'academy' ), 'capability' => 'manage_options', ]; } if ( self::get_addon_active_status( 'tutor-booking' ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-tutor-booking' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Tutor Bookings', 'academy' ), 'capability' => 'manage_options', // The page's route tabs (not a submenu). Kept apart from // `sub_items` so a group can still use the page as its link; // global search indexes both. 'tab_items' => [ [ 'slug' => '', 'title' => __( 'All Bookings', 'academy' ), ], [ 'slug' => 'category', 'title' => __( 'Category', 'academy' ), ], [ 'slug' => 'tags', 'title' => __( 'Tags', 'academy' ), ], [ 'slug' => 'booked', 'title' => __( 'Booked Schedules', 'academy' ), ] ] ]; }//end if if ( self::get_addon_active_status( 'assignments' ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-assignments' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Assignments', 'academy' ), 'capability' => 'manage_options', // The page's route tabs (not a submenu). Kept apart from // `sub_items` so a group can still use the page as its link; // global search indexes both. 'tab_items' => [ [ 'slug' => '', 'title' => __( 'All Assignments', 'academy' ), ], [ 'slug' => 'submitted-assignments', 'title' => __( 'Submitted Assignments', 'academy' ), ] ] ]; } if ( self::get_addon_active_status( 'course-bundle' ) && ( self::is_active_woocommerce() || class_exists( \StoreEngine::class ) ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-course-bundle' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Course Bundle', 'academy' ), 'capability' => 'manage_options', ]; } if ( self::get_addon_active_status( 'google-classroom' ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-google-classroom' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Google Classroom', 'academy' ), 'capability' => 'manage_options' ]; } if ( self::get_addon_active_status( 'group-plus' ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-group-plus' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Group Plus', 'academy' ), 'capability' => 'manage_options', ]; }//end if if ( self::get_addon_active_status( 'course-active-timer' ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-course-timer' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Course Active Timer', 'academy' ), 'capability' => 'manage_options', ]; } }//end if $menu[ ACADEMY_PLUGIN_SLUG . '-announcements' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Announcements', 'academy' ), 'capability' => 'manage_options', ]; $menu[ ACADEMY_PLUGIN_SLUG . '-question_answer' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Question & Answer', 'academy' ), 'capability' => 'manage_options', ]; if ( self::is_active_academy_pro() && self::get_addon_active_status( 'grade_book' ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-grade-book' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Gradebook', 'academy' ), 'capability' => 'manage_options', // The page's route tabs (not a submenu). Kept apart from // `sub_items` so a group can still use the page as its link; // global search indexes both. 'tab_items' => [ [ 'slug' => '', 'title' => __( 'Student Grades', 'academy' ), ], [ 'slug' => 'builder', 'title' => __( 'Grade Builder', 'academy' ), ], ], ]; } if ( self::get_addon_active_status( 'multi_instructor' ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-withdraw' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Withdraw Requests', 'academy' ), 'capability' => 'manage_options', ]; } if ( self::get_addon_active_status( 'webhooks' ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-webhooks' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Webhooks', 'academy' ), 'capability' => 'manage_options', ]; } $menu[ ACADEMY_PLUGIN_SLUG . '-instructors' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Instructors', 'academy' ), 'capability' => 'manage_options', ]; if ( self::is_active_academy_pro() && self::get_addon_active_status( 'attendance' ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-attendance' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Attendance', 'academy' ), 'capability' => 'manage_options', ]; } $menu[ ACADEMY_PLUGIN_SLUG . '-students' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Students', 'academy' ), 'capability' => 'manage_options', ]; if ( self::get_addon_active_status( 'certificates' ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-certificates' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Certificates', 'academy' ), 'capability' => 'manage_options', ]; } // Incoming webhooks are the Incoming tab of the Webhooks screen, not a // menu entry of their own. $menu[ ACADEMY_PLUGIN_SLUG . '-addons' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Add-ons', 'academy' ), 'capability' => 'manage_options', ]; // "What's new!" is no longer a visible menu item — it is a hidden page // shown once per plugin update (see Menu::register_hidden_pages() and // Menu::maybe_redirect_whats_new()). $menu[ ACADEMY_PLUGIN_SLUG . '-tools' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Tools', 'academy' ), 'capability' => 'manage_options', ]; // How the site looks sits next to how it behaves. $menu[ ACADEMY_PLUGIN_SLUG . '-design' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Customize', 'academy' ), 'capability' => 'manage_options', // Shown beside the menu name. 'badge' => __( 'New', 'academy' ), ]; $menu[ ACADEMY_PLUGIN_SLUG . '-settings' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Settings', 'academy' ), 'capability' => 'manage_options', ]; // Discover promotes Kodezen's other plugins, so a white-labelled // dashboard leaves it out (and its page unregistered). if ( ! ( self::is_active_academy_pro() && self::get_addon_active_status( 'white-label' ) ) ) { $menu[ ACADEMY_PLUGIN_SLUG . '-discover' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( 'Discover', 'academy' ), 'capability' => 'manage_options', ]; } // Check Pro active or not if ( ! self::is_active_academy_pro() ) { $menu[ ACADEMY_PLUGIN_SLUG . '-get-pro' ] = [ 'parent_slug' => ACADEMY_PLUGIN_SLUG, 'title' => __( ' Get Pro', 'academy' ), 'capability' => 'manage_options', ]; } return self::order_admin_menu( self::nest_admin_menu_pages( apply_filters( 'academy/admin_menu_list', $menu ) ) ); } /** * Show some pages inside another item's submenu instead of as their own * top-level menu item. * * - Course Bundle and Certificates go under Courses. * - Groups (People, Engagement) have no page of their own: the first * available page is the group's link, renamed via `menu_title`, and * the rest become its children. A group with one available page stays * a plain item. * * The pages stay in the list, so they are still registered, keep their * `?page=` URLs and stay searchable; they are only marked `menu_parent`, * which hides them from the menu (see Menu::nested_pages_css() and the * React AdminMenu). Runs after the `academy/admin_menu_list` filter, so a * page removed there (e.g. by role permissions) is never linked. * * @param array $menu Admin menu list. * @return array */ public static function nest_admin_menu_pages( array $menu ) { $slug = ACADEMY_PLUGIN_SLUG; $courses = $slug . '-courses'; $nested = [ $slug . '-course-bundle' => __( 'Bundles', 'academy' ), $slug . '-certificates' => __( 'Certificates', 'academy' ), ]; if ( ! empty( $menu[ $courses ] ) ) { foreach ( $nested as $page => $title ) { if ( empty( $menu[ $page ] ) ) { continue; } $menu[ $courses ]['sub_items'][] = [ 'page' => $page, 'title' => $title, ]; $menu[ $page ]['menu_parent'] = $courses; } } $groups = [ [ 'title' => __( 'Content', 'academy' ), 'pages' => [ $slug . '-lessons' => __( 'Lessons', 'academy' ), $slug . '-quizzes' => __( 'Quizzes', 'academy' ), $slug . '-meeting' => __( 'Meeting', 'academy' ), $slug . '-assignments' => __( 'Assignments', 'academy' ), $slug . '-tutor-booking' => __( 'Tutor Bookings', 'academy' ), ], ], [ 'title' => __( 'People', 'academy' ), 'pages' => [ $slug . '-students' => __( 'Students', 'academy' ), $slug . '-instructors' => __( 'Instructors', 'academy' ), $slug . '-group-plus' => __( 'Groups', 'academy' ), $slug . '-withdraw' => __( 'Payouts', 'academy' ), ], ], [ 'title' => __( 'Engagement', 'academy' ), 'pages' => [ $slug . '-announcements' => __( 'Announcements', 'academy' ), $slug . '-question_answer' => __( 'Q&A', 'academy' ), $slug . '-course-timer' => __( 'Time Tracking', 'academy' ), ], ], ]; foreach ( $groups as $group ) { $pages = array_filter( $group['pages'], function ( $page ) use ( $menu ) { return ! empty( $menu[ $page ] ) && empty( $menu[ $page ]['menu_parent'] ); }, ARRAY_FILTER_USE_KEY ); if ( count( $pages ) < 2 ) { continue; } $anchor = array_key_first( $pages ); $sub_items = []; foreach ( $pages as $page => $title ) { $sub_items[] = [ 'page' => $page, 'title' => $title, ]; if ( $page !== $anchor ) { $menu[ $page ]['menu_parent'] = $anchor; } } $menu[ $anchor ]['menu_title'] = $group['title']; $menu[ $anchor ]['sub_items'] = $sub_items; $menu = self::move_admin_menu_item( $menu, $anchor, array_keys( $pages ) ); }//end foreach return $menu; } /** * Order the top-level menu into sections and mark where the divider * lines go. * * Each section lists page keys; a key that is nested in a group (or * missing: addon off, removed by role permissions) is skipped, so a * group is placed by whichever of its pages is its link. The last item * placed in each section gets `separator_after` (drawn by * Menu::separators_css()). Keys in no section (e.g. added by another * plugin through the filter) keep their order and go at the end. * * @param array $menu Admin menu list. * @return array */ public static function order_admin_menu( array $menu ) { $slug = ACADEMY_PLUGIN_SLUG; $sections = [ // Overview. [ $slug ], // What you teach. [ $slug . '-courses', $slug . '-lessons', $slug . '-quizzes', $slug . '-meeting', $slug . '-assignments', $slug . '-tutor-booking', $slug . '-google-classroom' ], // Who learns, and how they do. [ $slug . '-students', $slug . '-instructors', $slug . '-group-plus', $slug . '-withdraw', $slug . '-announcements', $slug . '-question_answer', $slug . '-course-timer', $slug . '-grade-book', $slug . '-attendance' ], // Extend and maintain. [ $slug . '-addons', $slug . '-webhooks', $slug . '-tools' ], // Look and configuration. [ $slug . '-design', $slug . '-settings', $slug . '-discover' ], ]; $ordered = []; foreach ( $sections as $section ) { $last = null; foreach ( $section as $key ) { if ( empty( $menu[ $key ] ) || ! empty( $menu[ $key ]['menu_parent'] ) || isset( $ordered[ $key ] ) ) { continue; } $ordered[ $key ] = $menu[ $key ]; $last = $key; } if ( $last ) { $ordered[ $last ]['separator_after'] = true; } } // Nothing after the final section's line. if ( $ordered ) { $keys = array_keys( $ordered ); unset( $ordered[ end( $keys ) ]['separator_after'] ); } // Everything not placed: nested pages and items from other code. return $ordered + $menu; } /** * Move a group's link to where its earliest page sat in the menu, so a * group shows up where its first member used to be. * * @param array $menu Admin menu list. * @param string $anchor Key to move. * @param array $members Keys of every page in the group. * @return array */ private static function move_admin_menu_item( array $menu, $anchor, array $members ) { $keys = array_keys( $menu ); $position = min( array_map( function ( $key ) use ( $keys ) { return array_search( $key, $keys, true ); }, $members ) ); $item = [ $anchor => $menu[ $anchor ] ]; unset( $menu[ $anchor ] ); return array_slice( $menu, 0, $position, true ) + $item + array_slice( $menu, $position, null, true ); } public static function get_addon_active_status( $addon_name, $is_pro = false ) { global $academy_addons; if ( $is_pro && ! self::is_active_academy_pro() ) { return false; } if ( isset( $academy_addons->{$addon_name} ) ) { return (bool) $academy_addons->{$addon_name}; } return false; } public static function is_active_academy_pro() { $academy_pro = 'academy-pro/academy-pro.php'; return self::is_plugin_active( $academy_pro ); } public static function is_active_ecm() { return class_exists( 'EasyContentManager' ); } /** * QuizPress is an "Extensions & Integrations" companion plugin, not a * toggleable addon — the integration is on whenever the plugin is active. */ public static function is_active_quizpress() { return self::is_plugin_active( 'quizpress/quizpress.php' ); } public static function is_active_ablocks() { $ablocks = 'ablocks/ablocks.php'; return self::is_plugin_active( $ablocks ); } public static function is_active_storeengine() { $storeengine = 'storeengine/storeengine.php'; return self::is_plugin_active( $storeengine ); } public static function is_active_zencommunity() { $zencommunity = 'zencommunity/zencommunity.php'; return self::is_plugin_active( $zencommunity ); } public static function is_active_gemsecurity() { $gemsecurity = 'gemsecurity/gemsecurity.php'; return self::is_plugin_active( $gemsecurity ); } /** * Whether GemSecurity is active AND its own Social Login module reports * active via the `gemsecurity/module_active` filter. Backs the * both-active escalation in the Social Login deprecation notices — * default `false` (unlike `is_active_gemsecurity()`'s callers, which * default `true` for the Login Security check) so an ambiguous or * absent signal never triggers a false "you have both enabled" claim. * * @return bool */ public static function is_gemsecurity_social_login_active() { return self::is_active_gemsecurity() && has_filter( 'gemsecurity/module_active' ) && (bool) apply_filters( 'gemsecurity/module_active', false, 'social-login' ); } public static function is_plugin_active( $basename ) { if ( ! function_exists( 'get_plugins' ) ) { include_once ABSPATH . '/wp-admin/includes/plugin.php'; } return is_plugin_active( $basename ); } /** * Whether course pages use block templates: always in block themes, and in * classic themes that opt in with `academy/templates/use_block_templates` * (unless the site kept the original course page layout). * * @return bool */ public static function use_block_templates() { if ( self::is_fse_theme() ) { return true; } return 'legacy' !== \Academy\Blocks::template_style() && (bool) apply_filters( 'academy/templates/use_block_templates', false ); } public static function is_fse_theme() { if ( function_exists( 'wp_is_block_theme' ) ) { return (bool) \wp_is_block_theme(); } if ( function_exists( 'gutenberg_is_fse_theme' ) ) { return (bool) \gutenberg_is_fse_theme(); } return false; } public static function monetization_engine() { return self::get_settings( 'monetization_engine' ); } public static function get_settings( $key, $default = null ) { global $academy_settings; if ( isset( $academy_settings->{$key} ) ) { return $academy_settings->{$key}; } return $default; } /** * Check current screen is academy admin page or not * * @return bool */ public static function is_academy_admin_page() { if ( is_admin() ) { $screen = get_current_screen(); // get_current_screen() returns null when called before the // `current_screen` hook has fired (e.g. while the setup wizard page // renders), so guard before reading ->base. if ( ! $screen instanceof \WP_Screen ) { return false; } return self::plugin_page_hook_suffix( $screen->base ); } return false; } /** * Check Supported Post type for admin page and plugin main settings page * * @param string $hook * * @return bool */ public static function plugin_page_hook_suffix( $hook ) { if ( is_string( $hook ) && strpos( $hook, '_page_' . ACADEMY_PLUGIN_SLUG ) !== false ) { return true; } return false; } public static function is_dev_mode_enable() { $environment = wp_get_environment_type(); if ( 'local' === $environment || 'development' === $environment ) { return true; } } public static function get_customizer_settings( $key, $default = null ) { $customizer_settings = get_option( 'academy_customizer_settings' ); if ( isset( $customizer_settings[ $key ] ) ) { return $customizer_settings[ $key ]; } return $default; } public static function get_customizer_style_settings( $key, $default = null ) { $customizer_settings = get_option( 'academy_customizer_style_settings' ); if ( isset( $customizer_settings[ $key ] ) ) { return $customizer_settings[ $key ]; } return $default; } public static function get_column_size( $number_of_column ) { return ceil( 12 / $number_of_column ); } public static function get_responsive_column( $columns ) { if ( is_array( $columns ) ) { $device = array( 'desktop' => 'lg', 'tablet' => 'md', 'mobile' => 'sm', ); $classes = ''; foreach ( $columns as $mode => $column ) { if ( $column ) { $classes .= ' academy-col-' . $device[ $mode ] . '-' . ceil( 12 / $column ); } } return ltrim( $classes ); } return ''; } /** * Get template part (for templates like the course-loop). * * ACADEMY_TEMPLATE_DEBUG_MODE will prevent overrides in themes from taking priority. * * @param mixed $slug Template slug. * @param string $name Template name (default: ''). */ public static function get_template_part( $slug, $name = '' ) { $template = false; if ( $name ) { $template = ACADEMY_TEMPLATE_DEBUG_MODE ? '' : locate_template( array( "{$slug}-{$name}.php", self::template_path() . "{$slug}-{$name}.php", ) ); if ( ! $template ) { $fallback = self::plugin_path() . "/templates/{$slug}-{$name}.php"; $template = file_exists( $fallback ) ? $fallback : ''; } } if ( ! $template ) { // If template file doesn't exist, look in yourtheme/slug.php and yourtheme/academy/slug.php. $template = ACADEMY_TEMPLATE_DEBUG_MODE ? '' : locate_template( array( "{$slug}.php", self::template_path() . "{$slug}.php", ) ); } // Allow 3rd party plugins to filter template file from their plugin. $template = apply_filters( 'academy/get_template_part', $template, $slug, $name ); if ( $template ) { load_template( $template, false ); } } /** * Get the template path. * * @return string */ public static function template_path() { return apply_filters( 'academy/template_path', 'academy/' ); } /** * Get the template path. * * @return string */ public static function plugin_path() { return apply_filters( 'academy/plugin_path', ACADEMY_ROOT_DIR_PATH ); } /** * Get other templates (e.g. course attributes) passing attributes and including the file. * * @param string $template_name Template name. * @param array $args Arguments. (default: array). * @param string $template_path Template path. (default: ''). * @param string $default_path Default path. (default: ''). */ public static function get_template( $template_name, $args = array(), $template_path = '', $default_path = '' ) { $template = false; if ( ! $template ) { $template = self::locate_template( $template_name, $template_path, $default_path ); } // Allow 3rd party plugin filter template file from their plugin. $filter_template = apply_filters( 'academy/get_template', $template, $template_name, $args, $template_path, $default_path ); if ( $filter_template !== $template ) { if ( ! file_exists( $filter_template ) ) { /* translators: %s template */ wc_doing_it_wrong( __FUNCTION__, sprintf( __( '%s does not exist.', 'academy' ), '' . $filter_template . '' ), '1.0.0' ); return; } $template = $filter_template; } $action_args = array( 'template_name' => $template_name, 'template_path' => $template_path, 'located' => $template, 'args' => $args, ); if ( ! empty( $args ) && is_array( $args ) ) { if ( isset( $args['action_args'] ) ) { wc_doing_it_wrong( __FUNCTION__, __( 'action_args should not be overwritten when calling academy/get_template.', 'academy' ), '1.0.0' ); unset( $args['action_args'] ); } extract( $args ); // @codingStandardsIgnoreLine } do_action( 'academy/before_template_part', $action_args['template_name'], $action_args['template_path'], $action_args['located'], $action_args['args'] ); include $action_args['located']; do_action( 'academy/after_template_part', $action_args['template_name'], $action_args['template_path'], $action_args['located'], $action_args['args'] ); } /** * Locate a template and return the path for inclusion. * * This is the load order: * * yourtheme/$template_path/$template_name * yourtheme/$template_name * $default_path/$template_name * * @param string $template_name Template name. * @param string $template_path Template path. (default: ''). * @param string $default_path Default path. (default: ''). * * @return string */ public static function locate_template( $template_name, $template_path = '', $default_path = '' ) { if ( ! $template_path ) { $template_path = self::template_path(); } if ( ! $default_path ) { $default_path = self::plugin_path() . 'templates/'; } // Look within passed path within the theme - this is priority. if ( false !== strpos( $template_name, 'academy_courses_category' ) || false !== strpos( $template_name, 'academy_courses_tag' ) ) { $cs_template = str_replace( '_', '-', $template_name ); $template = locate_template( array( trailingslashit( $template_path ) . $cs_template, $cs_template, ) ); } if ( empty( $template ) ) { $template = locate_template( array( trailingslashit( $template_path ) . $template_name, $template_name, ) ); } // Get default template/. if ( ! $template || ACADEMY_TEMPLATE_DEBUG_MODE ) { if ( empty( $cs_template ) ) { $template = $default_path . $template_name; } else { $template = $default_path . $cs_template; } } // Return what we found. return apply_filters( 'academy/locate_template', $template, $template_name, $template_path ); } /** * Academy Date Format - Allows to change date format for everything Academy. * * @return string */ public static function get_date_format() { $date_format = get_option( 'date_format' ); if ( empty( $date_format ) ) { // Return default date format if the option is empty. $date_format = 'F j, Y'; } return apply_filters( 'academy/date_format', $date_format ); } public static function string_to_array( $string ) { if ( empty( $string ) ) { return []; } $string = explode( "\n", $string ); return array_filter( array_map( 'trim', $string ) ); } public static function calculate_percentage( $total_count, $completed_count ) { if ( $total_count > 0 && $completed_count > 0 ) { return min( number_format( ( $completed_count / $total_count ) * 100 ), 100 ); } return 0; } public static function youtube_id_from_url( $url ) { $parts = wp_parse_url( $url ); if ( isset( $parts['query'] ) ) { parse_str( $parts['query'], $qs ); if ( isset( $qs['v'] ) ) { return $qs['v']; } elseif ( isset( $qs['vi'] ) ) { return $qs['vi']; } } if ( isset( $parts['path'] ) ) { $path = explode( '/', trim( $parts['path'], '/' ) ); return $path[ count( $path ) - 1 ]; } return false; } public static function parse_embedded_url( $string ) { if ( wp_http_validate_url( $string ) ) { $url = ''; if ( str_contains( wp_parse_url( $string )['host'], 'canva.com' ) ) { $url = add_query_arg( 'embed', '', $string ); } else { $oembed = _wp_oembed_get_object(); $url = $oembed->get_provider( $string ); } return array( 'url' => $url ? $url : $string, ); } preg_match( '/src=["\'](.*?)["\']/i', $string, $src ); preg_match( '/allow=["\'](.*?)["\']/i', $string, $allow ); preg_match( '/width=["\'](.*?)["\']/i', $string, $width ); preg_match( '/height=["\'](.*?)["\']/i', $string, $height ); return array( 'url' => ( isset( $src[1] ) ? $src[1] : '' ), 'allow' => ( isset( $allow[1] ) ? $allow[1] : '' ), 'width' => ( isset( $width[1] ) ? $width[1] : '' ), 'height' => ( isset( $height[1] ) ? $height[1] : '' ), ); } public static function vimeo_id_from_url( $url ) { if ( preg_match( '/(https?:\/\/)?(www\.)?(player\.)?vimeo\.com\/([a-z]*\/)*([0-9]{6,11})[?]?.*/', $url, $output_array ) ) { return $output_array[5]; } } /** * Privacy hash of an unlisted/private Vimeo video, or '' when the URL has * none. Vimeo refuses to embed such a video without it, and * vimeo_id_from_url() keeps only the numeric id, so callers that reduce a * URL to its id carry this alongside it. * * Recognises both shapes Vimeo hands out: * https://vimeo.com/{id}/{hash} * https://player.vimeo.com/video/{id}?h={hash} * * Only a short hex token is returned, so the value is safe to put in a * query string or a data attribute. * * @param string $url Vimeo page, player or embed URL. * @return string */ public static function vimeo_hash_from_url( $url ) { $url = html_entity_decode( (string) $url, ENT_QUOTES ); $id = self::vimeo_id_from_url( $url ); if ( ! $id ) { return ''; } $query = wp_parse_url( $url, PHP_URL_QUERY ); if ( $query ) { parse_str( $query, $args ); if ( isset( $args['h'] ) && is_string( $args['h'] ) && self::is_valid_vimeo_hash( $args['h'] ) ) { return $args['h']; } } if ( preg_match( '#vimeo\.com/(?:[a-z]+/)*' . preg_quote( $id, '#' ) . '/([a-f0-9]+)(?:[/?\#&]|$)#i', $url, $matches ) && self::is_valid_vimeo_hash( $matches[1] ) ) { return $matches[1]; } return ''; } public static function is_valid_vimeo_hash( $hash ) { return is_string( $hash ) && 1 === preg_match( '/^[a-f0-9]{6,20}$/i', $hash ); } /** * Vimeo player URL for a page/player/embed URL, keeping the privacy hash * (when there is one) so unlisted/private videos still play. * * @param string $url Vimeo page, player or embed URL. * @param array $args Extra player query args (e.g. title, byline). * @return string '' when no Vimeo id can be found. */ public static function vimeo_player_url( $url, $args = array() ) { $id = self::vimeo_id_from_url( $url ); if ( ! $id ) { return ''; } $hash = self::vimeo_hash_from_url( $url ); if ( $hash ) { $args = array( 'h' => $hash ) + $args; } return add_query_arg( $args, 'https://player.vimeo.com/video/' . $id ); } /** * Resolve a bare URL down to the canonical provider KodezenPlayer natively * plays, or null if it doesn't match one — an opaque third-party embed * (Canva, Kaltura, …) with no native provider, kept on the iframe fallback. * * @param string $resolved An already-unwrapped URL — e.g. the `url` field * parse_embedded_url() returns for an "embedded" * source, or a raw "external" URL. * @return string|null */ public static function resolve_trackable_provider( $resolved ) { if ( empty( $resolved ) ) { return null; } if ( self::is_html5_video_link( $resolved ) ) { return 'html5'; } $host = wp_parse_url( $resolved, PHP_URL_HOST ); if ( $host && ( str_contains( $host, 'youtube.com' ) || str_contains( $host, 'youtu.be' ) ) ) { return 'youtube'; } if ( self::vimeo_id_from_url( $resolved ) ) { return 'vimeo'; } return null; } /** * Rewrite a video_source array in place to the native provider shape * (`youtube`/`vimeo`/`html5`) once `$resolved` has been matched by * resolve_trackable_provider() — or leave `$video['url']` as the opaque * `$fallback_url` shape (e.g. parse_embedded_url()'s {url,allow,...}) * if nothing matched. Shared by the `embedded` and `external` branches * of Lesson::render_lesson() so both stay in sync as hosts are added. * * @param array $video The video_source array (by reference). * @param string $resolved The already-unwrapped URL to resolve — * for a host WP's oEmbed recognizes, this may * already be an iframe embed src rather than * the canonical page URL. * @param mixed $fallback_url What to leave `$video['url']` as when * nothing matches (kept opaque/untracked). */ public static function apply_resolved_video_provider( array &$video, $resolved, $fallback_url ) { $provider = self::resolve_trackable_provider( $resolved ); switch ( $provider ) { case 'youtube': $video['type'] = 'youtube'; $video['url'] = self::youtube_id_from_url( $resolved ); break; case 'vimeo': $video['type'] = 'vimeo'; $video['hash'] = self::vimeo_hash_from_url( $resolved ); $video['url'] = self::vimeo_id_from_url( $resolved ); break; case 'html5': $video['type'] = 'html5'; $video['url'] = $resolved; break; default: $video['url'] = $fallback_url; break; } } /** * Every video_source type KodezenPlayer natively plays + tracks. Kept as * one list so is_trackable_video_source() and anything else that needs * "is this a native player type" stay in sync as providers are added. */ public static function native_player_provider_types() { return [ 'html5', 'youtube', 'vimeo' ]; } /** * URL of the WebVTT subtitle file attached to a lesson's video source * (`video_source.subtitle`, an attachment ID), or '' when there is none or * the attachment isn't a .vtt file. KodezenPlayer renders it as captions * for any native provider type. * * @param array|mixed $video The stored video_source array. * @return string */ public static function get_video_subtitle_url( $video ) { $attachment_id = is_array( $video ) ? absint( $video['subtitle'] ?? 0 ) : 0; if ( ! $attachment_id || 'text/vtt' !== get_post_mime_type( $attachment_id ) ) { return ''; } return (string) wp_get_attachment_url( $attachment_id ); } /** * Whether a lesson's video source can actually be watch-tracked — i.e. it * plays through a player that reports progress back to the server * (a native provider type, or a direct match for one pasted as an * "external"/"embedded" source), as opposed to an opaque third-party * iframe with no completion signal at all. Used to gate both the * frontend's resume/lock-seek data and the server's watch-percentage * completion check consistently. * * @param string $type The stored video_source type. * @param string|array $url The stored/resolved URL — a plain string, or * (for an already-resolved "embedded" source) * the array shape returned by parse_embedded_url(). */ public static function is_trackable_video_source( $type, $url ) { if ( in_array( $type, self::native_player_provider_types(), true ) ) { return true; } if ( ! in_array( $type, [ 'external', 'embedded' ], true ) ) { return false; } if ( 'embedded' === $type ) { $resolved = is_array( $url ) ? ( $url['url'] ?? '' ) : ( self::parse_embedded_url( (string) $url )['url'] ?? '' ); } else { $resolved = is_array( $url ) ? ( $url['url'] ?? '' ) : $url; } return (bool) self::resolve_trackable_provider( $resolved ); } /** * Whether a lesson's video source is the opaque third-party iframe * fallback (Wistia, Vidyard, Twitch, SoundCloud, Mixcloud, Facebook, * Kaltura, or any other embed/external source with no native provider * match) that nonetheless has a real src to play. A cross-origin iframe * reports no playback position or duration to the parent page, so these * lessons get a coarse dwell-time (seconds the lesson stayed open and * visible) watch gate instead of the real percent-of-duration one. * * @param string $type The stored video_source type. * @param string|array $url The stored/resolved URL — same shape accepted * by is_trackable_video_source(). */ public static function is_dwell_trackable_video_source( $type, $url ) { if ( ! in_array( $type, [ 'external', 'embedded' ], true ) ) { return false; } if ( 'embedded' === $type ) { $resolved = is_array( $url ) ? ( $url['url'] ?? '' ) : ( self::parse_embedded_url( (string) $url )['url'] ?? '' ); } else { $resolved = is_array( $url ) ? ( $url['url'] ?? '' ) : $url; } // A native match means the real percent-of-duration gate applies instead. if ( self::resolve_trackable_provider( $resolved ) ) { return false; } return ! empty( $resolved ); } public static function gumlet_id_from_url( $url ) { if ( preg_match( '/src="([^"]+)"/', $url, $srcMatch ) ) { $src = $srcMatch[1]; // Step 2: extract the ID from the src URL after /embed/ if ( preg_match( '#/embed/([^?]+)#', $src, $idMatch ) ) { $id = $idMatch[1]; return $id; } } } /** * Extract ScreenPal video ID from embed markup or URL. * * @param string $content Embed markup or URL. * @return string|null Video ID on success, null on failure. */ public static function screenpal_id_from_url( $content ) { if ( empty( $content ) || ! is_string( $content ) ) { return null; } $pattern = '#data-id="([^"]+)"|(?:player|watch)/([A-Za-z0-9]+)#'; if ( preg_match( $pattern, $content, $matches ) ) { // First capturing group (data-id) OR second (player/) $video_id = ! empty( $matches[1] ) ? $matches[1] : $matches[2]; return sanitize_text_field( $video_id ); } return null; } public static function generate_video_embed_url( $url ) { if ( strpos( $url, 'youtube' ) > 0 || strpos( $url, 'youtu.be' ) > 0 ) { return 'https://www.youtube.com/embed/' . self::youtube_id_from_url( $url ); } elseif ( strpos( $url, 'vimeo' ) > 0 ) { if ( ! self::vimeo_id_from_url( $url ) ) { // Unchanged output for a URL with no Vimeo id. return 'https://player.vimeo.com/video/?title=0&byline=0'; } return self::vimeo_player_url( $url, array( 'title' => 0, 'byline' => 0, ) ); } elseif ( strpos( $url, 'gumlet' ) > 0 ) { return 'https://play.gumlet.io/embed/' . self::gumlet_id_from_url( $url ); } elseif ( strpos( $url, 'screenpal' ) > 0 ) { return 'https://go.screenpal.com/player/' . self::screenpal_id_from_url( $url ); } return $url; } public static function is_active_woocommerce() { include_once ABSPATH . 'wp-admin/includes/plugin.php'; return is_plugin_active( 'woocommerce/woocommerce.php' ); } public static function sanitize_text_or_array_field( $array_or_string ) { $boolean = [ 'true', 'false', '1', '0' ]; if ( is_string( $array_or_string ) ) { $array_or_string = in_array( $array_or_string, $boolean, true ) || is_bool( $array_or_string ) ? rest_sanitize_boolean( $array_or_string ) : sanitize_text_field( $array_or_string ); } elseif ( is_array( $array_or_string ) ) { foreach ( $array_or_string as $key => &$value ) { if ( is_array( $value ) ) { $value = self::sanitize_text_or_array_field( $value ); } else { $value = in_array( $value, $boolean, true ) || is_bool( $value ) ? rest_sanitize_boolean( $value ) : sanitize_text_field( $value ); } } } return $array_or_string; } public static function sanitize_checkbox_field( $boolean ) { return filter_var( sanitize_text_field( $boolean ), FILTER_VALIDATE_BOOLEAN ); } public static function sanitize_referer_url( $referer_url ) { $parse_url = wp_parse_url( $referer_url ); if ( isset( $parse_url['query'] ) ) { // Parse query parameters parse_str( $parse_url['query'], $query_params ); if ( isset( $query_params['redirect_to'] ) && ! empty( $query_params['redirect_to'] ) ) { $referer_url = $query_params['redirect_to']; } if ( isset( $query_params['redirect_url'] ) && ! empty( $query_params['redirect_url'] ) ) { $referer_url = $query_params['redirect_url']; } } // Sanitize the input URL $referer_url = esc_url_raw( $referer_url ); if ( filter_var( $referer_url, FILTER_VALIDATE_URL ) !== false && wp_http_validate_url( $referer_url ) && strpos( $referer_url, home_url() ) === 0 ) { return esc_url( $referer_url ); } elseif ( isset( $parse_url['path'] ) && ! empty( $parse_url['path'] ) ) { return esc_url( home_url( sanitize_text_field( $parse_url['path'] ) ) ); } return esc_url( home_url( '/' ) ); } public static function get_current_term_id() { $queried = get_queried_object(); $current_term_id = ( is_object( $queried ) && property_exists( $queried, 'term_id' ) ) ? $queried->term_id : false; return $current_term_id; } public static function get_basic_url_to_embed_url( $url ) { $embedObject = wp_oembed_get( $url, $args = '' ); if ( $embedObject ) { return self::parse_embedded_url( $embedObject ); } return array( 'url' => self::generate_video_embed_url( $url ), 'allow' => '', ); } public static function minify_css( $css ) { $css = preg_replace( '/\/\*((?!\*\/).)*\*\//', '', $css ); $css = preg_replace( '/\s{2,}/', ' ', $css ); $css = preg_replace( '/\s*([:;{}])\s*/', '$1', $css ); $css = preg_replace( '/;}/', '}', $css ); return $css; } public static function get_permalink_structure() { $saved_permalinks = (array) get_option( 'academy_permalinks', array() ); $permalinks = wp_parse_args( array_filter( $saved_permalinks ), array( 'course_base' => _x( 'course', 'slug', 'academy' ), 'category_base' => _x( 'course-category', 'slug', 'academy' ), 'tag_base' => _x( 'course-tag', 'slug', 'academy' ), 'use_verbose_page_rules' => false, ) ); if ( $saved_permalinks !== $permalinks ) { update_option( 'academy_permalinks', $permalinks ); } $permalinks['course_rewrite_slug'] = untrailingslashit( $permalinks['course_base'] ); $permalinks['category_rewrite_slug'] = untrailingslashit( $permalinks['category_base'] ); $permalinks['tag_rewrite_slug'] = untrailingslashit( $permalinks['tag_base'] ); return $permalinks; } public static function sanitize_permalink( $value ) { global $wpdb; $value = $wpdb->strip_invalid_text_for_column( $wpdb->options, 'option_value', $value ); if ( is_wp_error( $value ) ) { $value = ''; } $value = esc_url_raw( trim( $value ) ); $value = str_replace( 'http://', '', $value ); return untrailingslashit( $value ); } /** * Recursively get page children. * * @param int $page_id Page ID. * * @return int[] */ public static function get_page_children( $page_id ) { $page_ids = get_posts( array( 'post_parent' => $page_id, 'post_type' => 'page', 'numberposts' => - 1, // @codingStandardsIgnoreLine 'post_status' => 'any', 'fields' => 'ids', ) ); if ( ! empty( $page_ids ) ) { foreach ( $page_ids as $page_id ) { $page_ids = array_merge( $page_ids, self::get_page_children( $page_id ) ); } } return $page_ids; } public static function is_plugin_installed( $basename ) { if ( ! function_exists( 'get_plugins' ) ) { include_once ABSPATH . '/wp-admin/includes/plugin.php'; } $installed_plugins = get_plugins(); return isset( $installed_plugins[ $basename ] ); } public static function get_client_ip_address() { $ip_address = ''; if ( getenv( 'HTTP_CLIENT_IP' ) ) { $ip_address = getenv( 'HTTP_CLIENT_IP' ); } elseif ( getenv( 'REMOTE_ADDR' ) ) { $ip_address = getenv( 'REMOTE_ADDR' ); } elseif ( getenv( 'HTTP_FORWARDED_FOR' ) ) { $ip_address = getenv( 'HTTP_FORWARDED_FOR' ); } elseif ( getenv( 'HTTP_FORWARDED' ) ) { $ip_address = getenv( 'HTTP_FORWARDED' ); } elseif ( getenv( 'HTTP_X_FORWARDED_FOR' ) ) { $ip_address = getenv( 'HTTP_X_FORWARDED_FOR' ); } elseif ( getenv( 'HTTP_X_FORWARDED' ) ) { $ip_address = getenv( 'HTTP_X_FORWARDED' ); } return $ip_address; } /** * Whether a curriculum item's content must be rendered through the * isolated full-page frame (vs. inline in the SPA). True when it contains * a registered shortcode or produces inline