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