--- a/learnpress-import-export/assets/dist/js/admin/lp-migration-learndash.asset.php
+++ b/learnpress-import-export/assets/dist/js/admin/lp-migration-learndash.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array(), 'version' => 'dded6a922b4d1146dc43');
--- a/learnpress-import-export/assets/dist/js/admin/lp-migration-learndash.min.asset.php
+++ b/learnpress-import-export/assets/dist/js/admin/lp-migration-learndash.min.asset.php
@@ -0,0 +1 @@
+<?php return array('dependencies' => array(), 'version' => '1f0a1cd7a4c51a71320b');
--- a/learnpress-import-export/config/migration-plugin.php
+++ b/learnpress-import-export/config/migration-plugin.php
@@ -1,27 +1,43 @@
-<?php
-
-use LPImportExportMigrationHelpersPlugin;
-
-$is_tutor_active = Plugin::is_tutor_active();
-$config_data = array();
-
-if ( $is_tutor_active ) {
- $config_data['tutor'] = array(
- 'title' => esc_html__( 'Tutor LMS', 'learnpress-import-export' ),
- 'name' => 'tutor',
- 'icon' => LP_ADDON_IMPORT_EXPORT_ASSETS_URL . '/images/tutor-128x128.jpg',
- 'url' => add_query_arg(
- array(
- 'page' => 'lp-migration-tool',
- 'tab' => 'tutor'
- ),
- admin_url( 'admin.php' )
- ),
- 'desc' => esc_html__( 'Migrate the Tutor data to LearnPress with the LearnPress Migration Tool.', 'learnpress-import-export' )
- );
-}
-
-return apply_filters(
- 'learnpress-import-export/filter/config/migration-plugin',
- $config_data
-);
+<?php
+
+use LPImportExportMigrationHelpersPlugin;
+
+$is_tutor_active = Plugin::is_tutor_active();
+$config_data = array();
+
+if ( $is_tutor_active ) {
+ $config_data['tutor'] = array(
+ 'title' => esc_html__( 'Tutor LMS', 'learnpress-import-export' ),
+ 'name' => 'tutor',
+ 'icon' => LP_ADDON_IMPORT_EXPORT_ASSETS_URL . '/images/tutor-128x128.jpg',
+ 'url' => add_query_arg(
+ array(
+ 'page' => 'lp-migration-tool',
+ 'tab' => 'tutor'
+ ),
+ admin_url( 'admin.php' )
+ ),
+ 'desc' => esc_html__( 'Migrate the Tutor data to LearnPress with the LearnPress Migration Tool.', 'learnpress-import-export' )
+ );
+}
+
+if ( Plugin::is_learndash_active() ) {
+ $config_data['learndash'] = array(
+ 'title' => esc_html__( 'LearnDash', 'learnpress-import-export' ),
+ 'name' => 'learndash',
+ 'icon' => LP_ADDON_IMPORT_EXPORT_ASSETS_URL . '/images/learndash-128x128.png',
+ 'url' => add_query_arg(
+ array(
+ 'page' => 'lp-migration-tool',
+ 'tab' => 'learndash'
+ ),
+ admin_url( 'admin.php' )
+ ),
+ 'desc' => esc_html__( 'Migrate the LearnDash data to LearnPress with the LearnPress Migration Tool.', 'learnpress-import-export' )
+ );
+}
+
+return apply_filters(
+ 'learnpress-import-export/filter/config/migration-plugin',
+ $config_data
+);
--- a/learnpress-import-export/config/migration.php
+++ b/learnpress-import-export/config/migration.php
@@ -1,14 +1,14 @@
-<?php
-$course_cpt = defined( 'LP_COURSE_CPT' ) ? LP_COURSE_CPT : 'lp_course';
-
-return apply_filters(
- 'learnpress-import-export/filter/config/migration',
- array(
- 'page_title' => esc_html__( 'Migration Tool', 'learnpress-import-export' ),
- 'menu_title' => esc_html__( 'Migration Tool', 'learnpress-import-export' ),
- 'parent_slug' => 'learn_press',
- 'slug' => 'lp-migration-tool',
- 'capability' => 'administrator',
- 'name' => 'lp-migration-tool',
- )
-);
+<?php
+$course_cpt = defined( 'LP_COURSE_CPT' ) ? LP_COURSE_CPT : 'lp_course';
+
+return apply_filters(
+ 'learnpress-import-export/filter/config/migration',
+ array(
+ 'page_title' => esc_html__( 'Migration Tool', 'learnpress-import-export' ),
+ 'menu_title' => esc_html__( 'Migration Tool', 'learnpress-import-export' ),
+ 'parent_slug' => 'learn_press',
+ 'slug' => 'lp-migration-tool',
+ 'capability' => 'administrator',
+ 'name' => 'lp-migration-tool',
+ )
+);
--- a/learnpress-import-export/config/scripts.php
+++ b/learnpress-import-export/config/scripts.php
@@ -1,34 +1,41 @@
-<?php
-
-use LPImportExportMigrationHelpersSourceAsset;
-
-$source_asset = SourceAsset::getInstance();
-
-return apply_filters(
- 'learnpress-import-export/filter/config/scripts',
- array(
- 'admin' => array(
- 'register' => array(
- 'learnpress-import-export-global' => array(
- 'src' => $source_asset->get_asset_admin_file_url( 'js', 'learnpress-import-export-global' ),
- 'deps' => array( 'wp-api-fetch' ),
- ),
- 'lp-migration-tutor' => array(
- 'src' => $source_asset->get_asset_admin_file_url( 'js', 'lp-migration-tutor' ),
- 'deps' => array( 'wp-api-fetch' ),
- 'screens' => array(
- LP_ADDON_IMPORT_EXPORT_MIGRATION_PAGE,
- ),
- ),
- )
- ),
- 'frontend' => array(
- 'register' => array(
- 'learnpress-import-export-global' => array(
- 'src' => $source_asset->get_asset_frontend_file_url( 'js', 'learnpress-import-export-global' ),
- 'deps' => array( 'wp-api-fetch' ),
- )
- ),
- ),
- ),
-);
+<?php
+
+use LPImportExportMigrationHelpersSourceAsset;
+
+$source_asset = SourceAsset::getInstance();
+
+return apply_filters(
+ 'learnpress-import-export/filter/config/scripts',
+ array(
+ 'admin' => array(
+ 'register' => array(
+ 'learnpress-import-export-global' => array(
+ 'src' => $source_asset->get_asset_admin_file_url( 'js', 'learnpress-import-export-global' ),
+ 'deps' => array( 'wp-api-fetch' ),
+ ),
+ 'lp-migration-tutor' => array(
+ 'src' => $source_asset->get_asset_admin_file_url( 'js', 'lp-migration-tutor' ),
+ 'deps' => array( 'wp-api-fetch' ),
+ 'screens' => array(
+ LP_ADDON_IMPORT_EXPORT_MIGRATION_PAGE,
+ ),
+ ),
+ 'lp-migration-learndash' => array(
+ 'src' => $source_asset->get_asset_admin_file_url( 'js', 'lp-migration-learndash' ),
+ 'deps' => array( 'wp-api-fetch' ),
+ 'screens' => array(
+ LP_ADDON_IMPORT_EXPORT_MIGRATION_PAGE,
+ ),
+ ),
+ )
+ ),
+ 'frontend' => array(
+ 'register' => array(
+ 'learnpress-import-export-global' => array(
+ 'src' => $source_asset->get_asset_frontend_file_url( 'js', 'learnpress-import-export-global' ),
+ 'deps' => array( 'wp-api-fetch' ),
+ )
+ ),
+ ),
+ ),
+);
--- a/learnpress-import-export/config/styles.php
+++ b/learnpress-import-export/config/styles.php
@@ -1,27 +1,27 @@
-<?php
-
-use LPImportExportMigrationHelpersSourceAsset;
-
-$source_asset = SourceAsset::getInstance();
-
-return apply_filters(
- 'learnpress-import-export/filter/config/style',
- array(
- 'admin' => array(
- 'learnpress-import-export-admin' => array(
- 'src' => $source_asset->get_asset_admin_file_url(
- 'css',
- 'learnpress-import-export-admin'
- ),
- ),
- ),
- 'frontend' => array(
- 'learnpress-import-export-global' => array(
- 'src' => $source_asset->get_asset_frontend_file_url(
- 'css',
- 'learnpress-import-export-global'
- ),
- )
- ),
- )
-);
+<?php
+
+use LPImportExportMigrationHelpersSourceAsset;
+
+$source_asset = SourceAsset::getInstance();
+
+return apply_filters(
+ 'learnpress-import-export/filter/config/style',
+ array(
+ 'admin' => array(
+ 'learnpress-import-export-admin' => array(
+ 'src' => $source_asset->get_asset_admin_file_url(
+ 'css',
+ 'learnpress-import-export-admin'
+ ),
+ ),
+ ),
+ 'frontend' => array(
+ 'learnpress-import-export-global' => array(
+ 'src' => $source_asset->get_asset_frontend_file_url(
+ 'css',
+ 'learnpress-import-export-global'
+ ),
+ )
+ ),
+ )
+);
--- a/learnpress-import-export/inc/LearnDashMigration/LP_Curriculum_Patch.php
+++ b/learnpress-import-export/inc/LearnDashMigration/LP_Curriculum_Patch.php
@@ -0,0 +1,189 @@
+<?php
+/**
+ * LearnPress Curriculum Patch
+ *
+ * Fixes an issue where LearnPress CourseModel caching prevents curriculum from loading.
+ * The problem: LP_Course::get_full_sections_and_items_course() uses CourseModel::find(id, true)
+ * which caches an empty model before sections are populated.
+ *
+ * This class hooks into LearnPress and force-loads sections from database when they're empty.
+ *
+ * @package LearnPress_Import_Export
+ * @since 1.0.0
+ */
+
+namespace LPImportExportLearnDashMigration;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Class LP_Curriculum_Patch
+ */
+class LP_Curriculum_Patch {
+
+ /**
+ * Singleton instance.
+ *
+ * @var LP_Curriculum_Patch
+ */
+ private static $instance = null;
+
+ /**
+ * Get singleton instance.
+ *
+ * @return LP_Curriculum_Patch
+ */
+ public static function instance() {
+ if ( null === self::$instance ) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Constructor - hook into LearnPress.
+ */
+ private function __construct() {
+ // Hook into course sections filter to fix empty curriculum.
+ add_filter( 'learn-press/course-sections', array( $this, 'fix_empty_sections' ), 10, 4 );
+ }
+
+ /**
+ * Fix empty sections by loading from database directly.
+ *
+ * @param array $sections The sections array (may be empty due to caching bug).
+ * @param int $course_id The course ID.
+ * @param string $return Return type.
+ * @param int $section_id Specific section ID (0 for all).
+ * @return array Fixed sections array.
+ */
+ public function fix_empty_sections( $sections, $course_id, $return, $section_id ) {
+ // Only fix if sections are empty but database has data.
+ if ( ! empty( $sections ) ) {
+ return $sections;
+ }
+
+ global $wpdb;
+
+ // Check if this course has sections in the database.
+ $db_sections_count = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->prefix}learnpress_sections WHERE section_course_id = %d",
+ $course_id
+ )
+ );
+
+ // No sections in DB either - nothing to fix.
+ if ( ! $db_sections_count ) {
+ return $sections;
+ }
+
+ // Load sections directly from database.
+ $sections_data = $this->load_sections_from_db( $course_id );
+
+ // If we got sections, create LP_Course_Section objects.
+ if ( empty( $sections_data ) ) {
+ return $sections;
+ }
+
+ // Build section objects.
+ $fixed_sections = array();
+ $position = 0;
+
+ foreach ( $sections_data as $section_data ) {
+ ++$position;
+ $section_id_key = $section_data['section_id'];
+
+ // Create LP_Course_Section if class exists.
+ if ( class_exists( 'LP_Course_Section' ) ) {
+ $section_items = array(
+ 'section_id' => $section_data['section_id'],
+ 'section_name' => $section_data['section_name'],
+ 'section_course_id' => $course_id,
+ 'section_order' => $section_data['section_order'],
+ 'section_description' => $section_data['section_description'] ?? '',
+ 'items' => $section_data['items'] ?? array(),
+ );
+
+ $section = new LP_Course_Section( $section_items );
+ $section->set_position( $position );
+ $fixed_sections[ $section_id_key ] = $section;
+ }
+ }
+
+ return $fixed_sections;
+ }
+
+ /**
+ * Load sections and items directly from database.
+ *
+ * @param int $course_id Course ID.
+ * @return array Sections data.
+ */
+ private function load_sections_from_db( $course_id ) {
+ global $wpdb;
+
+ $sections_data = array();
+
+ // Get sections.
+ $sections = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$wpdb->prefix}learnpress_sections
+ WHERE section_course_id = %d
+ ORDER BY section_order",
+ $course_id
+ )
+ );
+
+ if ( empty( $sections ) ) {
+ return $sections_data;
+ }
+
+ // Get section items.
+ $section_ids = wp_list_pluck( $sections, 'section_id' );
+ $section_ids_str = implode( ',', array_map( 'intval', $section_ids ) );
+
+ $items = $wpdb->get_results(
+ "SELECT si.*, p.post_title, p.post_type, p.post_status
+ FROM {$wpdb->prefix}learnpress_section_items si
+ INNER JOIN {$wpdb->posts} p ON si.item_id = p.ID
+ WHERE si.section_id IN ({$section_ids_str})
+ AND p.post_status = 'publish'
+ ORDER BY si.section_id, si.item_order"
+ );
+
+ // Group items by section.
+ $items_by_section = array();
+ foreach ( $items as $item ) {
+ if ( ! isset( $items_by_section[ $item->section_id ] ) ) {
+ $items_by_section[ $item->section_id ] = array();
+ }
+
+ $item_obj = new stdClass();
+ $item_obj->id = $item->item_id;
+ $item_obj->item_id = $item->item_id;
+ $item_obj->order = $item->item_order;
+ $item_obj->item_order = $item->item_order;
+ $item_obj->type = $item->item_type;
+ $item_obj->item_type = $item->item_type;
+ $item_obj->title = html_entity_decode( $item->post_title );
+
+ $items_by_section[ $item->section_id ][ $item->item_id ] = $item_obj;
+ }
+
+ // Build sections data.
+ foreach ( $sections as $section ) {
+ $section_items = $items_by_section[ $section->section_id ] ?? array();
+
+ $sections_data[] = array(
+ 'section_id' => $section->section_id,
+ 'section_name' => html_entity_decode( $section->section_name ),
+ 'section_order' => $section->section_order,
+ 'section_description' => html_entity_decode( $section->section_description ?? '' ),
+ 'items' => $section_items,
+ );
+ }
+
+ return $sections_data;
+ }
+}
--- a/learnpress-import-export/inc/LearnDashMigration/LearnDashDataDump.php
+++ b/learnpress-import-export/inc/LearnDashMigration/LearnDashDataDump.php
@@ -0,0 +1,626 @@
+<?php
+/**
+ * LearnDash Data Dump Class
+ *
+ * @package LPImportExportLearnDashMigration
+ */
+
+namespace LPImportExportLearnDashMigration;
+
+use LDLMS_Factory_Post;
+use WpProQuiz_Model_QuizMapper;
+use WpProQuiz_Model_QuestionMapper;
+
+/**
+ * Deprecated
+ * Class LearnDashDataDump
+ * Handles dumping LearnDash course data for migration to LearnPress.
+ */
+class LearnDashDataDump {
+
+ /**
+ * Get course data.
+ *
+ * @param WP_Post $course Course post object.
+ *
+ * @return array
+ */
+ public function get_course_data( $course ) {
+ $settings = learndash_get_setting( $course->ID );
+ $feature_image = null;
+ $thumbnail_id = get_post_thumbnail_id( $course->ID );
+
+ if ( $thumbnail_id ) {
+ $feature_image = wp_get_attachment_url( $thumbnail_id );
+ }
+
+ $price = ! empty( $settings['course_price'] ) ? $settings['course_price'] : null;
+
+ return array(
+ 'id' => $course->ID,
+ 'title' => $course->post_title,
+ 'slug' => $course->post_name,
+ 'status' => $course->post_status,
+ 'content' => $course->post_content,
+ 'excerpt' => $course->post_excerpt,
+ 'author_id' => $course->post_author,
+ 'created_at' => $course->post_date,
+ 'modified_at' => $course->post_modified,
+ 'feature_image' => $feature_image,
+ 'price' => $price,
+ 'settings' => $settings,
+ 'meta' => $this->get_all_meta( $course->ID ),
+ 'sections' => $this->dump_sections( $course->ID ),
+ );
+ }
+
+ /**
+ * Dump course sections with full item data (LP-style structure).
+ *
+ * @param int $course_id Course ID.
+ * @return array Array of sections with embedded items.
+ */
+ public function dump_sections( $course_id ) {
+ $lessons = $this->dump_lessons( $course_id );
+ $lesson_map = $this->build_lesson_map( $lessons );
+ $lesson_ids = $this->get_lesson_ids( $course_id );
+
+ $ld_sections = $this->get_ld_sections( $course_id );
+ $section_bounds = $this->get_section_lesson_boundaries( $ld_sections );
+ $ordered_items = $this->build_ordered_items( $ld_sections, $section_bounds, $lesson_ids );
+
+ $sections_data = $this->build_sections_data( $ordered_items, $lesson_map );
+
+ // Add course-level quizzes as final section.
+ $quiz_section = $this->build_quiz_section( $course_id, count( $sections_data ) + 1 );
+ if ( $quiz_section ) {
+ $sections_data[] = $quiz_section;
+ }
+
+ return $sections_data;
+ }
+
+ /**
+ * Build lesson lookup map by ID.
+ */
+ private function build_lesson_map( array $lessons ): array {
+ $map = array();
+ foreach ( $lessons as $lesson ) {
+ $map[ $lesson['id'] ] = $lesson;
+ }
+ return $map;
+ }
+
+ /**
+ * Get lesson IDs in course order.
+ */
+ private function get_lesson_ids( int $course_id ): array {
+ if ( function_exists( 'learndash_course_get_steps_by_type' ) ) {
+ return learndash_course_get_steps_by_type( $course_id, 'sfwd-lessons' );
+ }
+ return array();
+ }
+
+ /**
+ * Get LearnDash sections sorted by order.
+ */
+ private function get_ld_sections( int $course_id ): array {
+ $sections = get_post_meta( $course_id, 'course_sections', true );
+ if ( is_string( $sections ) ) {
+ $sections = json_decode( $sections, true );
+ }
+ if ( empty( $sections ) || ! is_array( $sections ) ) {
+ return array();
+ }
+
+ usort( $sections, fn( $a, $b ) => intval( $a['order'] ?? 0 ) <=> intval( $b['order'] ?? 0 ) );
+ return $sections;
+ }
+
+ /**
+ * Calculate lesson boundaries for each section.
+ * Returns array of [lesson_start, lesson_end] for each section index.
+ */
+ private function get_section_lesson_boundaries( array $sections ): array {
+ $bounds = array();
+ foreach ( $sections as $i => $section ) {
+ $order = intval( $section['order'] ?? 0 );
+ $start = $order - $i;
+
+ if ( isset( $sections[ $i + 1 ]['order'] ) ) {
+ $end = intval( $sections[ $i + 1 ]['order'] ) - ( $i + 1 );
+ } else {
+ $end = PHP_INT_MAX;
+ }
+
+ $bounds[ $i ] = array(
+ 'start' => $start,
+ 'end' => $end,
+ );
+ }
+ return $bounds;
+ }
+
+ /**
+ * Build ordered items list (sections + unassigned lessons).
+ */
+ private function build_ordered_items( array $sections, array $bounds, array $lesson_ids ): array {
+ $items = array();
+ $lessons_in_section = array();
+
+ // Add sections with their lessons.
+ foreach ( $sections as $i => $section ) {
+ $section_lessons = array();
+ foreach ( $lesson_ids as $idx => $lid ) {
+ if ( $idx >= $bounds[ $i ]['start'] && $idx < $bounds[ $i ]['end'] ) {
+ $section_lessons[] = $lid;
+ $lessons_in_section[] = $lid;
+ }
+ }
+
+ $items[] = array(
+ 'type' => 'section',
+ 'position' => intval( $section['order'] ?? 0 ) + 0.5,
+ 'title' => $section['post_title'] ?? '',
+ 'lesson_ids' => $section_lessons,
+ );
+ }
+
+ // Add unassigned lessons.
+ foreach ( $lesson_ids as $idx => $lid ) {
+ if ( ! in_array( $lid, $lessons_in_section, true ) ) {
+ $items[] = array(
+ 'type' => 'lesson',
+ 'position' => $idx,
+ 'lesson_id' => $lid,
+ );
+ }
+ }
+
+ usort( $items, fn( $a, $b ) => $a['position'] <=> $b['position'] );
+ return $items;
+ }
+
+ /**
+ * Build final sections data from ordered items.
+ */
+ private function build_sections_data( array $ordered_items, array $lesson_map ): array {
+ $sections_data = array();
+ $section_order = 0;
+
+ foreach ( $ordered_items as $item ) {
+ if ( 'section' === $item['type'] ) {
+ $section = $this->build_lp_section( $item['title'], $item['lesson_ids'], $lesson_map, ++$section_order );
+ if ( $section ) {
+ $sections_data[] = $section;
+ }
+ } else {
+ $lesson = $lesson_map[ $item['lesson_id'] ] ?? null;
+ if ( $lesson ) {
+ $lesson['item_type'] = 'lesson';
+ $lesson['item_order'] = 1;
+ $sections_data[] = array(
+ 'section_name' => $lesson['title'],
+ 'section_order' => ++$section_order,
+ 'section_description' => '',
+ 'items' => array( $lesson ),
+ );
+ }
+ }
+ }
+
+ return $sections_data;
+ }
+
+ /**
+ * Build a single LP section with its items.
+ */
+ private function build_lp_section( string $title, array $lesson_ids, array $lesson_map, int $order ): ?array {
+ if ( empty( $lesson_ids ) ) {
+ return null;
+ }
+
+ $items = array();
+ $item_order = 0;
+
+ foreach ( $lesson_ids as $lid ) {
+ if ( isset( $lesson_map[ $lid ] ) ) {
+ $lesson = $lesson_map[ $lid ];
+ $lesson['item_type'] = 'lesson';
+ $lesson['item_order'] = ++$item_order;
+ $items[] = $lesson;
+ }
+ }
+
+ if ( empty( $items ) ) {
+ return null;
+ }
+
+ return array(
+ 'section_name' => $title,
+ 'section_order' => $order,
+ 'section_description' => '',
+ 'items' => $items,
+ );
+ }
+
+ /**
+ * Build quiz section for course-level quizzes.
+ */
+ private function build_quiz_section( int $course_id, int $order ): ?array {
+ $quizzes = $this->dump_quizzes( $course_id, 'course' );
+ if ( empty( $quizzes ) ) {
+ return null;
+ }
+
+ $items = array();
+ foreach ( $quizzes as $i => $quiz ) {
+ $quiz['item_type'] = 'quiz';
+ $quiz['item_order'] = $i + 1;
+ $items[] = $quiz;
+ }
+
+ return array(
+ 'section_name' => 'Final Quizzes',
+ 'section_order' => $order,
+ 'section_description' => '',
+ 'items' => $items,
+ );
+ }
+
+
+
+ /**
+ * Dump lessons for a course.
+ *
+ * @param int $course_id Course ID.
+ *
+ * @return array
+ */
+ public function dump_lessons( $course_id ) {
+ $lessons_data = array();
+
+ // Use LearnDash's native function to get lessons.
+ if ( ! function_exists( 'learndash_course_get_steps_by_type' ) ) {
+ return $lessons_data;
+ }
+
+ $lesson_ids = learndash_course_get_steps_by_type( $course_id, 'sfwd-lessons' );
+
+ if ( empty( $lesson_ids ) ) {
+ return $lessons_data;
+ }
+
+ foreach ( $lesson_ids as $lesson_id ) {
+ $lesson_post = get_post( $lesson_id );
+
+ if ( ! $lesson_post ) {
+ continue;
+ }
+
+ $lessons_data[] = array(
+ 'id' => $lesson_post->ID,
+ 'title' => $lesson_post->post_title,
+ 'slug' => $lesson_post->post_name,
+ 'status' => $lesson_post->post_status,
+ 'content' => $lesson_post->post_content,
+ 'order' => $lesson_post->menu_order,
+ 'settings' => learndash_get_setting( $lesson_post->ID ),
+ 'meta' => $this->get_all_meta( $lesson_post->ID ),
+ 'topics' => $this->dump_topics( $lesson_post->ID, $course_id ),
+ 'quizzes' => $this->dump_quizzes( $lesson_post->ID, 'lesson' ),
+ );
+ }
+
+ return $lessons_data;
+ }
+
+ /**
+ * Dump topics for a lesson.
+ *
+ * @param int $lesson_id Lesson ID.
+ * @param int $course_id Course ID.
+ *
+ * @return array
+ */
+ public function dump_topics( $lesson_id, $course_id ) {
+ $topics_data = array();
+
+ // Use LearnDash's native function to get topics.
+ if ( ! function_exists( 'learndash_get_topic_list' ) ) {
+ return $topics_data;
+ }
+
+ $topics = learndash_get_topic_list( $lesson_id, $course_id );
+
+ if ( empty( $topics ) ) {
+ return $topics_data;
+ }
+
+ foreach ( $topics as $topic ) {
+ $topic_post = is_object( $topic ) ? $topic : get_post( $topic );
+
+ if ( ! $topic_post ) {
+ continue;
+ }
+
+ $topics_data[] = array(
+ 'id' => $topic_post->ID,
+ 'title' => $topic_post->post_title,
+ 'slug' => $topic_post->post_name,
+ 'status' => $topic_post->post_status,
+ 'content' => $topic_post->post_content,
+ 'order' => $topic_post->menu_order,
+ 'settings' => learndash_get_setting( $topic_post->ID ),
+ 'meta' => $this->get_all_meta( $topic_post->ID ),
+ 'quizzes' => $this->dump_quizzes( $topic_post->ID, 'topic' ),
+ );
+ }
+
+ return $topics_data;
+ }
+
+ /**
+ * Dump quizzes for a parent.
+ *
+ * @param int $parent_id Parent ID.
+ * @param string $parent_type Parent type (course, lesson, topic).
+ *
+ * @return array
+ */
+ public function dump_quizzes( $parent_id, $parent_type ) {
+ $quizzes = array();
+
+ // For course-level quizzes, use steps function.
+ if ( 'course' === $parent_type ) {
+ if ( ! function_exists( 'learndash_course_get_steps_by_type' ) ) {
+ return $quizzes;
+ }
+
+ $quiz_ids = learndash_course_get_steps_by_type( $parent_id, 'sfwd-quiz' );
+
+ // Filter to only get quizzes directly attached to course (not lessons).
+ $quiz_ids = array_filter(
+ $quiz_ids,
+ function ( $quiz_id ) use ( $parent_id ) {
+ $quiz_course_id = learndash_get_setting( $quiz_id, 'course' );
+ $quiz_lesson_id = learndash_get_setting( $quiz_id, 'lesson' );
+
+ // Only include if course matches and no lesson association.
+ return intval( $quiz_course_id ) === intval( $parent_id ) && empty( $quiz_lesson_id );
+ }
+ );
+
+ foreach ( $quiz_ids as $quiz_id ) {
+ $quiz_post = get_post( $quiz_id );
+
+ if ( ! $quiz_post ) {
+ continue;
+ }
+
+ $quiz_pro_id = learndash_get_setting( $quiz_post->ID, 'quiz_pro' );
+ $quizzes[] = array(
+ 'id' => $quiz_post->ID,
+ 'title' => $quiz_post->post_title,
+ 'slug' => $quiz_post->post_name,
+ 'status' => $quiz_post->post_status,
+ 'content' => $quiz_post->post_content,
+ 'order' => $quiz_post->menu_order,
+ 'quiz_pro_id' => $quiz_pro_id,
+ 'settings' => learndash_get_setting( $quiz_post->ID ),
+ 'meta' => $this->get_all_meta( $quiz_post->ID ),
+ 'pro_quiz' => $this->get_pro_quiz_data( $quiz_pro_id ),
+ 'questions' => $this->dump_questions( $quiz_post->ID, $quiz_pro_id ),
+ );
+ }
+
+ return $quizzes;
+ }
+
+ // For lesson/topic quizzes, use quiz list function.
+ if ( ! function_exists( 'learndash_get_lesson_quiz_list' ) ) {
+ return $quizzes;
+ }
+
+ $quiz_list = learndash_get_lesson_quiz_list( $parent_id );
+
+ if ( empty( $quiz_list ) ) {
+ return $quizzes;
+ }
+
+ foreach ( $quiz_list as $quiz_item ) {
+ $quiz_post = isset( $quiz_item['post'] ) ? $quiz_item['post'] : null;
+
+ if ( ! $quiz_post ) {
+ continue;
+ }
+
+ $quiz_pro_id = learndash_get_setting( $quiz_post->ID, 'quiz_pro' );
+ $quizzes[] = array(
+ 'id' => $quiz_post->ID,
+ 'title' => $quiz_post->post_title,
+ 'slug' => $quiz_post->post_name,
+ 'status' => $quiz_post->post_status,
+ 'content' => $quiz_post->post_content,
+ 'order' => $quiz_post->menu_order,
+ 'quiz_pro_id' => $quiz_pro_id,
+ 'settings' => learndash_get_setting( $quiz_post->ID ),
+ 'meta' => $this->get_all_meta( $quiz_post->ID ),
+ 'pro_quiz' => $this->get_pro_quiz_data( $quiz_pro_id ),
+ 'questions' => $this->dump_questions( $quiz_post->ID, $quiz_pro_id ),
+ );
+ }
+
+ return $quizzes;
+ }
+
+ /**
+ * Get Pro Quiz data.
+ *
+ * @param int $quiz_pro_id Pro Quiz ID.
+ *
+ * @return array|null
+ */
+ private function get_pro_quiz_data( $quiz_pro_id ) {
+ if ( empty( $quiz_pro_id ) || ! class_exists( 'WpProQuiz_Model_QuizMapper' ) ) {
+ return null;
+ }
+
+ $quiz_mapper = new WpProQuiz_Model_QuizMapper();
+ $quiz = $quiz_mapper->fetch( $quiz_pro_id );
+
+ if ( ! $quiz ) {
+ return null;
+ }
+
+ return array(
+ 'id' => $quiz->getId(),
+ 'name' => $quiz->getName(),
+ 'text' => $quiz->getText(),
+ 'result_text' => $quiz->getResultText(),
+ 'title_hidden' => $quiz->isTitleHidden(),
+ 'question_random' => $quiz->isQuestionRandom(),
+ 'answer_random' => $quiz->isAnswerRandom(),
+ 'time_limit' => $quiz->getTimeLimit(),
+ 'statistics_on' => $quiz->isStatisticsOn(),
+ );
+ }
+
+ /**
+ * Dump questions for a quiz.
+ *
+ * @param int $quiz_id Quiz post ID.
+ * @param int $quiz_pro_id Pro Quiz ID.
+ *
+ * @return array
+ */
+ public function dump_questions( $quiz_id, $quiz_pro_id ) {
+ $questions = array();
+
+ if ( ! class_exists( 'LDLMS_Factory_Post' ) ) {
+ return $questions;
+ }
+
+ $ld_quiz_questions_object = LDLMS_Factory_Post::quiz_questions( $quiz_id );
+
+ if ( ! $ld_quiz_questions_object ) {
+ return $questions;
+ }
+
+ $question_posts = $ld_quiz_questions_object->get_questions();
+
+ if ( empty( $question_posts ) ) {
+ return $questions;
+ }
+
+ $question_mapper = new WpProQuiz_Model_QuestionMapper();
+
+ foreach ( $question_posts as $question_post_id => $question_pro_id ) {
+ $question_post = get_post( $question_post_id );
+
+ if ( ! $question_post ) {
+ continue;
+ }
+
+ $pro_question = ! empty( $question_pro_id ) ? $question_mapper->fetch( $question_pro_id ) : null;
+
+ $questions[] = array(
+ 'id' => $question_post->ID,
+ 'title' => $question_post->post_title,
+ 'slug' => $question_post->post_name,
+ 'status' => $question_post->post_status,
+ 'content' => $question_post->post_content,
+ 'order' => $question_post->menu_order,
+ 'question_pro_id' => $question_pro_id,
+ 'settings' => learndash_get_setting( $question_post->ID ),
+ 'meta' => $this->get_all_meta( $question_post->ID ),
+ 'pro_question' => $this->get_pro_question_data( $pro_question ),
+ 'answers' => $this->get_question_answers( $pro_question ),
+ );
+ }
+
+ return $questions;
+ }
+
+ /**
+ * Get Pro Question data.
+ *
+ * @param WpProQuiz_Model_Question|null $pro_question Pro Question object.
+ *
+ * @return array|null
+ */
+ private function get_pro_question_data( $pro_question ) {
+ if ( ! $pro_question || ! is_a( $pro_question, 'WpProQuiz_Model_Question' ) ) {
+ return null;
+ }
+
+ return array(
+ 'id' => $pro_question->getId(),
+ 'quiz_id' => $pro_question->getQuizId(),
+ 'answer_type' => $pro_question->getAnswerType(),
+ 'points' => $pro_question->getPoints(),
+ );
+ }
+
+ /**
+ * Get question answers.
+ *
+ * @param WpProQuiz_Model_Question|null $pro_question Pro Question object.
+ *
+ * @return array
+ */
+ private function get_question_answers( $pro_question ) {
+ if ( ! $pro_question || ! is_a( $pro_question, 'WpProQuiz_Model_Question' ) ) {
+ return array();
+ }
+
+ $answer_data = $pro_question->getAnswerData();
+
+ if ( empty( $answer_data ) ) {
+ return array();
+ }
+
+ $answers = array();
+
+ foreach ( $answer_data as $index => $answer ) {
+ if ( ! is_a( $answer, 'WpProQuiz_Model_AnswerTypes' ) ) {
+ continue;
+ }
+
+ $answers[] = array(
+ 'index' => $index,
+ 'answer' => $answer->getAnswer(),
+ 'sort_string' => $answer->getSortString(), // Match target for matrix_sort_answer questions.
+ 'is_html' => $answer->isHtml(),
+ 'points' => $answer->getPoints(),
+ 'is_correct' => $answer->isCorrect(),
+ );
+ }
+
+ return array(
+ 'type' => $pro_question->getAnswerType(),
+ 'options' => $answers,
+ );
+ }
+
+ /**
+ * Get all post meta.
+ *
+ * @param int $post_id Post ID.
+ *
+ * @return array
+ */
+ private function get_all_meta( $post_id ) {
+ $meta = get_post_meta( $post_id );
+ $clean_meta = array();
+
+ foreach ( $meta as $key => $values ) {
+ if ( strpos( $key, '_edit_' ) === 0 || '_wp_old_slug' === $key ) {
+ continue;
+ }
+ $clean_meta[ $key ] = count( $values ) === 1 ? maybe_unserialize( $values[0] ) : array_map( 'maybe_unserialize', $values );
+ }
+
+ return $clean_meta;
+ }
+}
--- a/learnpress-import-export/inc/LearnDashMigration/LearnDashHelper.php
+++ b/learnpress-import-export/inc/LearnDashMigration/LearnDashHelper.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace LPImportExportLearnDashMigration;
+
+/**
+ * LearnDash Helper Class
+ *
+ * Provides utility methods for the LearnDash to LearnPress migration process.
+ * Contains static methods for retrieving migration state, totals, and progress data.
+ * Used by the migration UI to display progress bars and status information.
+ *
+ * @package LPImportExportLearnDashMigration
+ * @since 1.0.0
+ */
+class LearnDashHelper {
+
+ /**
+ * Get migration data for UI display.
+ *
+ * Compiles all migration step information including labels, descriptions,
+ * total counts, and current progress. Also includes migration metadata
+ * such as when the last migration was performed and by which user.
+ *
+ * @since 1.0.0
+ * @return array {
+ * Migration data array.
+ *
+ * @type array $migration_items Associative array of migration steps (content, student_migrate).
+ * @type int $learndash_migrate_time Unix timestamp of last completed migration, or 0 if never run.
+ * @type int $learndash_migrate_user_id User ID who performed the last migration, or 0 if never run.
+ * }
+ */
+ public static function get_data() {
+ $data = array();
+
+ // Map LearnDash steps to labels/descriptions for the UI.
+ $data['migration_items'] = array(
+ 'content' => array(
+ 'label' => esc_html__( 'Content Migration', 'learnpress-import-export' ),
+ 'description' => esc_html__( 'Migrating courses, lessons, topics, quizzes, and questions to LearnPress.', 'learnpress-import-export' ),
+ 'total' => self::get_content_total(),
+ 'migrated' => self::get_migrated_total( 'content' ),
+ ),
+ 'student_migrate' => array(
+ 'label' => esc_html__( 'Student Progress Migration', 'learnpress-import-export' ),
+ 'description' => esc_html__( 'Migrating user course progress and quiz attempts to LearnPress.', 'learnpress-import-export' ),
+ 'total' => self::get_student_migrate_total(),
+ 'migrated' => self::get_migrated_total( 'student_migrate' ),
+ ),
+ );
+
+ $data['learndash_migrate_time'] = get_option( 'learndash_migrate_time', 0 );
+ $data['learndash_migrate_user_id'] = get_option( 'learndash_migrate_user_id', 0 );
+
+ return $data;
+ }
+
+ /**
+ * Get the total number of LearnDash courses for content migration step.
+ *
+ * Counts all LearnDash courses (sfwd-courses post type) regardless of status.
+ * This determines the total iterations needed for the content step progress bar.
+ *
+ * @since 1.0.0
+ * @return int Total count of LearnDash courses.
+ */
+ public static function get_content_total() {
+ $courses = get_posts(
+ array(
+ 'post_type' => 'sfwd-courses',
+ 'posts_per_page' => -1,
+ 'post_status' => 'any',
+ 'fields' => 'ids',
+ )
+ );
+ return count( $courses );
+ }
+
+ /**
+ * Get the total number of users for student migration step.
+ *
+ * First tries to count distinct users from learndash_user_activity table
+ * where activity_type='course'. Falls back to counting all wp_users
+ * if the LearnDash activity table doesn't exist.
+ *
+ * @since 1.0.0
+ * @global wpdb $wpdb WordPress database object.
+ * @return int Total count of users with LearnDash progress to migrate.
+ */
+ public static function get_student_migrate_total() {
+ global $wpdb;
+
+ $table_name = $wpdb->prefix . 'learndash_user_activity';
+
+ // Check if learndash_user_activity table exists.
+ $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
+
+ if ( $table_exists ) {
+ // Count distinct users with course activity.
+ return (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(DISTINCT user_id) FROM {$table_name} WHERE activity_type = %s",
+ 'course'
+ )
+ );
+ }
+
+ // Fallback: count all users.
+ return (int) $wpdb->get_var( "SELECT COUNT(ID) FROM {$wpdb->users}" );
+ }
+
+ /**
+ * Get the number of migrated items for a specific step.
+ *
+ * Retrieves the current migration progress for each step from WordPress options.
+ * Used to update the progress bar UI during migration.
+ *
+ * @since 1.0.0
+ * @param string $item Migration step key: 'content' or 'student_migrate'.
+ * @return int Number of items that have been migrated for the specified step.
+ */
+ public static function get_migrated_total( $item ) {
+ if ( $item === 'content' ) {
+ return (int) get_option( 'learndash_migrated_content_count', 0 );
+ }
+ if ( $item === 'student_migrate' ) {
+ return (int) get_option( 'learndash_migrated_student_migrate_count', 0 );
+ }
+ return 0;
+ }
+}
--- a/learnpress-import-export/inc/LearnDashMigration/LearnDashMigrationController.php
+++ b/learnpress-import-export/inc/LearnDashMigration/LearnDashMigrationController.php
@@ -0,0 +1,246 @@
+<?php
+
+namespace LPImportExportLearnDashMigration;
+
+use LPImportExportMigrationHelpersRestApi;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+use Exception;
+
+/**
+ * LearnDash Migration Controller
+ *
+ * @package LPImportExportLearnDashMigration
+ */
+class LearnDashMigrationController {
+
+ /**
+ * Constructor.
+ *
+ * Initializes the migration controller by registering REST API routes
+ * and initializing the curriculum patch to fix LearnPress caching issues.
+ *
+ * @since 1.0.0
+ */
+ public function __construct() {
+ add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
+
+ // Initialize curriculum patch to fix LearnPress caching issue.
+ LP_Curriculum_Patch::instance();
+ }
+
+ /**
+ * Register REST API routes for LearnDash migration.
+ *
+ * Registers two endpoints:
+ * - POST /migrate/learndash: Main migration endpoint that handles all migration steps
+ * - DELETE /delete-migrated-data/learndash: Cleanup endpoint to remove migrated data
+ *
+ * @since 1.0.0
+ * @return void
+ */
+ public function register_rest_routes() {
+ register_rest_route(
+ RestApi::generate_namespace(),
+ '/migrate/learndash',
+ array(
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'permission_callback' => array( $this, 'check_admin_permission' ),
+ 'callback' => array( $this, 'migrate' ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ RestApi::generate_namespace(),
+ '/delete-migrated-data/learndash',
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'permission_callback' => array( $this, 'check_admin_permission' ),
+ 'callback' => array( $this, 'delete_migrated_data' ),
+ ),
+ );
+ }
+
+ /**
+ * Check if the current user has admin permission.
+ *
+ * Permission callback for REST API endpoints.
+ * Only users with 'manage_options' capability can access migration endpoints.
+ *
+ * @since 1.0.0
+ * @return bool True if user has admin permission, false otherwise.
+ */
+ public function check_admin_permission() {
+ return current_user_can( 'manage_options' );
+ }
+
+ /**
+ * Main migration handler.
+ *
+ * Routes migration requests to the appropriate step based on the 'item' parameter.
+ * Migration steps are executed in sequence:
+ * 1. content - Fetch and migrate courses, lessons, topics, and quizzes to LearnPress
+ * 2. student_migrate - Migrate student progress directly to LearnPress
+ *
+ * @since 1.0.0
+ * @param WP_REST_Request $request REST request containing:
+ * - item: Migration step ('content', 'student_migrate')
+ * - paged: Current page number for batch processing
+ * - number: Number of items to process per batch
+ * @return WP_REST_Response Success or error response with migration progress data.
+ */
+ public function migrate( WP_REST_Request $request ) {
+ try {
+ $params = $request->get_params();
+ $item = $params['item'] ?? 'content';
+ $paged = $params['paged'] ?? 1;
+ $number = $params['number'] ?? 10;
+
+ switch ( $item ) {
+ case 'content':
+ return $this->step_content( $paged, $number );
+ case 'student_migrate':
+ return $this->step_student_migrate( $paged, $number );
+ default:
+ throw new Exception( __( 'Invalid migration step.', 'learnpress-import-export' ) );
+ }
+ } catch ( Exception $e ) {
+ return RestApi::error( $e->getMessage() );
+ }
+ }
+
+ /**
+ * Step 1: Migrate content to LearnPress.
+ *
+ * Fetches LearnDash courses directly and converts them to LearnPress format, including:
+ * - Course structure and metadata
+ * - Lessons converted to LP sections
+ * - Topics converted to LP lessons
+ * - Quizzes and questions
+ * - Associated media and attachments
+ *
+ * @since 1.0.0
+ * @param int $paged Current page number (1-indexed).
+ * @param int $number Number of courses to migrate per batch.
+ * @return WP_REST_Response Response with migrated count, next page, and next migration step.
+ */
+ protected function step_content( $paged, $number ) {
+ // Clear previous data when starting fresh (page 1).
+ if ( $paged == 1 ) {
+ delete_option( 'learndash_migrated_content' );
+ delete_option( 'learndash_migrated_content_count' );
+ delete_option( 'learndash_migrated_student_migrate_count' );
+ }
+
+ // Fetch LearnDash courses directly.
+ $courses = get_posts(
+ array(
+ 'post_type' => 'sfwd-courses',
+ 'posts_per_page' => $number,
+ 'offset' => ( $paged - 1 ) * $number,
+ 'post_status' => 'any',
+ 'orderby' => 'title',
+ 'order' => 'ASC',
+ )
+ );
+
+ $total = LearnDashHelper::get_content_total();
+
+ // Build and migrate each course directly.
+ $dumper = new LearnDashDataDump();
+ $migrator = new LearnDashToLearnPressMigration();
+
+ foreach ( $courses as $course ) {
+ $ld_course = $dumper->get_course_data( $course );
+ $ld_course['lessons'] = $dumper->dump_lessons( $course->ID );
+ $ld_course['quizzes'] = $dumper->dump_quizzes( $course->ID, 'course' );
+
+ $migrator->migrate_course( $ld_course );
+ }
+
+ $migrated_count = get_option( 'learndash_migrated_content_count', 0 ) + count( $courses );
+ update_option( 'learndash_migrated_content_count', $migrated_count );
+
+ $data = array(
+ 'migrated_total' => $migrated_count,
+ 'next_page' => $paged + 1,
+ 'next_migrate_item' => 'content',
+ );
+
+ if ( $migrated_count >= $total ) {
+ $data['next_page'] = 1;
+ $data['next_migrate_item'] = 'student_migrate';
+ }
+
+ return RestApi::success( __( 'Migrating content...', 'learnpress-import-export' ), $data );
+ }
+
+ /**
+ * Step 3: Migrate student progress to LearnPress.
+ *
+ * Migrates student progress directly from LearnDash database to LearnPress.
+ * No JSON files - fetches and migrates user data in real-time.
+ * Creates user items (enrollments, lessons, quizzes) in the learnpress_user_items table.
+ * On completion, records the migration timestamp and user who performed the migration.
+ *
+ * @since 1.0.0
+ * @param int $paged Current page number (1-indexed).
+ * @param int $number Number of users to process per batch.
+ * @return WP_REST_Response Response with migrated count, next page, and success message on completion.
+ */
+ protected function step_student_migrate( $paged, $number ) {
+ $student_migrator = new LearnDashStudentDataMigration();
+ $result = $student_migrator->migrate_users_direct( $paged, $number );
+
+ $migrated_count = get_option( 'learndash_migrated_student_migrate_count', 0 ) + $result['processed'];
+ update_option( 'learndash_migrated_student_migrate_count', $migrated_count );
+
+ $data = array(
+ 'migrated_total' => $migrated_count,
+ 'next_page' => $paged + 1,
+ 'next_migrate_item' => 'student_migrate',
+ );
+
+ // Stop when no more users to process.
+ if ( ! $result['has_more'] ) {
+ $data['next_page'] = 1;
+ $data['next_migrate_item'] = false;
+ $data['migrate_success_html'] = '<p>' . __( 'LearnDash migration completed successfully!', 'learnpress-import-export' ) . '</p>';
+ update_option( 'learndash_migrate_time', time() );
+ update_option( 'learndash_migrate_user_id', get_current_user_id() );
+ }
+
+ return RestApi::success( __( 'Migrating student progress...', 'learnpress-import-export' ), $data );
+ }
+
+ /**
+ * Delete all migrated data and reset migration state.
+ *
+ * Removes all migration-related WordPress options.
+ * This allows the migration to be run again from scratch.
+ * Does NOT delete the actual LearnPress content that was created - only the migration tracking data.
+ *
+ * Options cleared:
+ * - Content migration progress
+ * - Student migration progress
+ * - Migration timestamp and user info
+ *
+ * @since 1.0.0
+ * @param WP_REST_Request $request REST request (unused but required by REST API).
+ * @return WP_REST_Response Success response confirming data was cleared.
+ */
+ public function delete_migrated_data( WP_REST_Request $request ) {
+
+ delete_option( 'learndash_migrated_content' );
+ delete_option( 'learndash_migrated_content_count' );
+ delete_option( 'learndash_migration_mapping' );
+ delete_option( 'learndash_migrated_student_migrate_count' );
+ delete_option( 'learndash_migrate_time' );
+ delete_option( 'learndash_migrate_user_id' );
+
+ return RestApi::success( __( 'Cleared all LearnDash migrated data and progress.', 'learnpress-import-export' ) );
+ }
+}
--- a/learnpress-import-export/inc/LearnDashMigration/LearnDashStudentDataMigration.php
+++ b/learnpress-import-export/inc/LearnDashMigration/LearnDashStudentDataMigration.php
@@ -0,0 +1,918 @@
+<?php
+/**
+ * LearnDash Student Data Migration Class (Refactored)
+ *
+ * Uses LearnPress Models from inc/Models/UserItems/ for proper data creation.
+ *
+ * @package LPImportExportLearnDashMigration
+ */
+
+namespace LPImportExportLearnDashMigration;
+
+use LearnPressModelsUserItemsUserCourseModel;
+use LearnPressModelsUserItemsUserLessonModel;
+use LearnPressModelsUserItemsUserQuizModel;
+use LearnPressModelsCourseModel;
+use LearnPressModelsCoursePostModel;
+use LP_Datetime;
+
+/**
+ * Class LearnDashStudentDataMigration
+ * Handles migrating LearnDash student progress data to LearnPress using LP Models.
+ */
+class LearnDashStudentDataMigration {
+
+ /**
+ * Batch size for processing.
+ *
+ * @var int
+ */
+ private $batch_size = 20;
+
+ /**
+ * Constructor.
+ */
+ public function __construct() {}
+
+ /**
+ * Get user courses with progress.
+ *
+ * @param int $user_id User ID.
+ * @param array $course_progress Course progress data from user meta.
+ * @return array
+ */
+ private function get_user_courses( $user_id, $course_progress ) {
+ $courses_data = array();
+ $processed_courses = array();
+
+ // First, process courses from user meta (_sfwd-course_progress).
+ if ( ! empty( $course_progress ) && is_array( $course_progress ) ) {
+ foreach ( $course_progress as $course_id => $progress ) {
+ $completed_on = learndash_user_get_course_completed_date( $user_id, $course_id );
+ $access_from = ld_course_access_from( $course_id, $user_id );
+
+ $courses_data[] = array(
+ 'course_id' => $course_id,
+ 'enrolled_date' => $access_from ? gmdate( 'Y-m-d H:i:s', $access_from ) : null,
+ 'completed' => ! empty( $completed_on ),
+ 'completed_date' => $completed_on ? gmdate( 'Y-m-d H:i:s', $completed_on ) : null,
+ 'progress' => $progress,
+ );
+ $processed_courses[ $course_id ] = true;
+ }
+ }
+
+ // Also check wp_learndash_user_activity table for course enrollments.
+ $activity_courses = $this->get_courses_from_activity_table( $user_id );
+ foreach ( $activity_courses as $activity ) {
+ $course_id = (int) $activity['course_id'];
+
+ // Skip if already processed from user meta.
+ if ( isset( $processed_courses[ $course_id ] ) ) {
+ continue;
+ }
+
+ $enrolled_date = $activity['activity_started'] ? gmdate( 'Y-m-d H:i:s', $activity['activity_started'] ) : null;
+ $completed_date = $activity['activity_completed'] ? gmdate( 'Y-m-d H:i:s', $activity['activity_completed'] ) : null;
+
+ $courses_data[] = array(
+ 'course_id' => $course_id,
+ 'enrolled_date' => $enrolled_date,
+ 'completed' => ! empty( $activity['activity_completed'] ),
+ 'completed_date' => $completed_date,
+ 'progress' => array(), // No detailed progress from activity table.
+ );
+ $processed_courses[ $course_id ] = true;
+ }
+
+ return $courses_data;
+ }
+
+ /**
+ * Check if user has any LearnDash activity in the activity table.
+ *
+ * @param int $user_id User ID.
+ * @return bool True if user has activity.
+ */
+ private function user_has_ld_activity( $user_id ) {
+ global $wpdb;
+
+ $table = $wpdb->prefix . 'learndash_user_activity';
+
+ // Check if table exists.
+ if ( ! $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) ) {
+ return false;
+ }
+
+ $count = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND activity_type = 'course'",
+ $user_id
+ )
+ );
+
+ return $count > 0;
+ }
+
+ /**
+ * Get course enrollments from wp_learndash_user_activity table.
+ *
+ * @param int $user_id User ID.
+ * @return array Array of course activity records.
+ */
+ private function get_courses_from_activity_table( $user_id ) {
+ global $wpdb;
+
+ $table = $wpdb->prefix . 'learndash_user_activity';
+
+ // Check if table exists.
+ if ( ! $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) ) {
+ return array();
+ }
+
+ return $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT course_id, activity_started, activity_completed, activity_status
+ FROM {$table}
+ WHERE user_id = %d AND activity_type = 'course'
+ ORDER BY activity_id DESC",
+ $user_id
+ ),
+ ARRAY_A
+ );
+ }
+
+ /**
+ * Get user quiz attempts.
+ *
+ * @param int $user_id User ID.
+ * @param array $quiz_attempts Quiz attempts data.
+ * @return array
+ */
+ private function get_user_quiz_attempts( $user_id, $quiz_attempts ) {
+ if ( empty( $quiz_attempts ) || ! is_array( $quiz_attempts ) ) {
+ return array();
+ }
+
+ return $quiz_attempts;
+ }
+
+ /**
+ * Migrate users directly without JSON dump files.
+ *
+ * Fetches users from database, builds their progress data, and migrates immediately.
+ * This is the real-time migration approach (Option A).
+ *
+ * @param int $paged Current page number (1-indexed).
+ * @param int $number Number of users to process per batch.
+ * @return array Contains 'processed' count and 'has_more' boolean.
+ */
+ public function migrate_users_direct( $paged, $number ) {
+ $offset = ( $paged - 1 ) * $number;
+
+ $users = get_users(
+ array(
+ 'number' => $number,
+ 'offset' => $offset,
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ )
+ );
+
+ $processed = 0;
+
+ foreach ( $users as $user ) {
+ $has_progress = get_user_meta( $user->ID, '_sfwd-course_progress', true );
+ $has_quizzes = get_user_meta( $user->ID, '_sfwd-quizzes', true );
+ $has_activity = $this->user_has_ld_activity( $user->ID );
+
+ // Skip users with no LearnDash data.
+ if ( empty( $has_progress ) && empty( $has_quizzes ) && ! $has_activity ) {
+ continue;
+ }
+
+ // Build and migrate user data directly.
+ $user_data = array(
+ 'id' => $user->ID,
+ 'enrolled_courses' => $this->get_user_courses( $user->ID, $has_progress ),
+ 'quiz_attempts' => $this->get_user_quiz_attempts( $user->ID, $has_quizzes ),
+ );
+
+ $this->migrate_user_data( $user_data );
+ ++$processed;
+ }
+
+ return array(
+ 'processed' => $processed,
+ 'has_more' => count( $users ) === $number,
+ );
+ }
+
+ /**
+ * Migrate single user data.
+ *
+ * @param array $user_data User data.
+ */
+ private function migrate_user_data( $user_data ) {
+ global $wpdb;
+
+ $user_id = $user_data['id'];
+
+ foreach ( $user_data['enrolled_courses'] as $course ) {
+ $lp_course_id = get_post_meta( $course['course_id'], '_lp_course_id', true );
+
+ if ( empty( $lp_course_id ) ) {
+ continue;
+ }
+
+ $user_item_id = $this->migrate_enrollment( $user_id, $lp_course_id, $course );
+
+ if ( ! $user_item_id ) {
+ continue;
+ }
+
+ // Clear student count cache for this course so count_students() returns correct value.
+ $this->clear_course_student_cache( $lp_course_id );
+
+ if ( ! empty( $course['progress']['lessons'] ) ) {
+ $this->migrate_lessons( $user_id, $course['progress']['lessons'], $lp_course_id, $user_item_id );
+ }
+
+ if ( ! empty( $course['progress']['topics'] ) ) {
+ foreach ( $course['progress']['topics'] as $topics ) {
+ $this->migrate_lessons( $user_id, $topics, $lp_course_id, $user_item_id, true );
+ }
+ }
+ }
+
+ foreach ( $user_data['quiz_attempts'] as $attempt ) {
+ $ld_quiz_id = $attempt['quiz'] ?? 0;
+ $lp_quiz_id = $ld_quiz_id ? get_post_meta( $ld_quiz_id, '_lp_quiz_id', true ) : 0;
+
+ if ( empty( $lp_quiz_id ) ) {
+ continue;
+ }
+
+ $lp_course_id = ( $attempt['course'] ?? 0 ) ? get_post_meta( $attempt['course'], '_lp_course_id', true ) : 0;
+ $parent_id = 0;
+
+ if ( $lp_course_id ) {
+ $parent_id = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT user_item_id FROM {$wpdb->prefix}learnpress_user_items WHERE user_id = %d AND item_id = %d AND item_type = 'lp_course'",
+ $user_id,
+ $lp_course_id
+ )
+ );
+ }
+
+ $this->migrate_quiz_attempt( $user_id, $lp_quiz_id, $lp_course_id, $parent_id, $attempt );
+ }
+ }
+
+ /**
+ * Migrate course enrollment.
+ *
+ * @param int $user_id User ID.
+ * @param int $lp_course_id LearnPress course ID.
+ * @param array $course Course data.
+ * @return int|false User item ID or false if already exists.
+ */
+ private function migrate_enrollment( $user_id, $lp_course_id, $course ) {
+ // Check if already exists using UserCourseModel.
+ $existing = UserCourseModel::find( $user_id, $lp_course_id, false );
+
+ if ( $existing instanceof UserCourseModel ) {
+ return $existing->get_user_item_id();
+ }
+
+ $userCourse = new UserCourseModel();
+ $userCourse->user_id = $user_id;
+ $userCourse->item_id = $lp_course_id;
+ $userCourse->start_time = $course['enrolled_date'] ?? current_time( 'mysql' );
+ $userCourse->end_time = $course['completed_date'] ?? null;
+ $userCourse->status = $course['completed'] ? 'finished' : 'enrolled';
+ $userCourse->graduation = $course['completed'] ? 'passed' : 'in-progress';
+ $userCourse->save();
+
+ $user_item_id = $userCourse->get_user_item_id();
+
+ // Calculate and save course results from LearnDash progress data.
+ if ( $user_item_id && ! empty( $course['progress'] ) ) {
+ $this->calculate_and_save_course_results(
+ $user_id,
+ $lp_course_id,
+ $user_item_id,
+ $course['progress'],
+ $course['completed']
+ );
+ }
+
+ return $user_item_id;
+ }
+
+ /**
+ * Calculate and save course results after enrollment migration.
+ *
+ * Mirrors LearnPressModelsUserItemsUserCourseModel::calculate_course_results()
+ * but uses LearnDash source data for completed/total items.
+ *
+ * @param int $user_id User ID.
+ * @param int $lp_course_id LearnPress course ID.
+ * @param int $user_item_id LearnPress user_item_id for the course enrollment.
+ * @param array $ld_progress LearnDash progress array with 'completed' and 'total' keys.
+ * @param bool $is_finished Whether the course is finished.
+ */
+ private function calculate_and_save_course_results( $user_id, $lp_course_id, $user_item_id, $ld_progress, $is_finished ) {
+ global $wpdb;
+
+ // Get LearnPress course model for evaluation type and passing condition.
+ $courseModel = CourseModel::find( $lp_course_id, true );
+ if ( ! $courseModel ) {
+ return;
+ }
+
+ // Calculate progress from LearnDash data.
+ $completed = isset( $ld_progress['completed'] ) ? (int) $ld_progress['completed'] : 0;
+ $total = isset( $ld_progress['total'] ) ? (int) $ld_progress['total'] : 0;
+ $result = $total > 0 ? round( ( $completed / $total ) * 100, 2 ) : 0;
+
+ // Get LP course item counts for reference.
+ $count_items = $courseModel->count_items();
+
+ // Determine if passed based on passing condition.
+ $passing_condition = $courseModel->get_passing_condition();
+ $pass = $result >= $passing_condition ? 1 : 0;
+
+ // If already finished in LearnDash, respect that status.
+ if ( $is_finished ) {
+ $pass = 1;
+ $result = max( $result, $passing_condition );
+ }
+
+ // Get evaluation type from course settings.
+ $evaluate_type = $courseModel->get_meta_value_by_key(
+ CoursePostModel::META_KEY_EVALUATION_TYPE,
+ 'evaluate_lesson'
+ );
+
+ // Build items breakdown (approximate from LD data).
+ $items = array(
+ 'lesson' => array(
+ 'completed' => isset( $ld_progress['lessons'] ) ? count( array_filter( $ld_progress['lessons'] ) ) : 0,
+ 'passed' => isset( $ld_progress['lessons'] ) ? count( array_filter( $ld_progress['lessons'] ) ) : 0,
+ 'total' => $courseModel->count_items( LP_LESSON_CPT ),
+ ),
+ 'quiz' => array(
+ 'completed' => 0, // Quiz progress handled separately.
+ 'passed' => 0,
+ 'total' => $courseModel->count_items( LP_QUIZ_CPT ),
+ ),
+ );
+
+ // Build result data matching LP's format.
+ $result_data = array(
+ 'count_items' => $count_items,
+ 'completed_items' => $completed,
+ 'items' => $items,
+ 'evaluate_type' => $evaluate_type,
+ 'pass' => $pass,
+ 'result' => $result,
+ );
+
+ // Insert or update in learnpress_user_item_results table using LP's DB class.
+ LP_User_Items_Result_DB::instance()->update( $user_item_id, wp_json_encode( $result_data ) );
+ }
+
+ /**
+ * Migrate lessons progress.
+ *
+ * @param int $user_id User ID.
+ * @param array $items Lesson items.
+ * @param int $lp_course_id LearnPress course ID.
+ * @param int $parent_id Parent item ID.
+ * @param bool $is_topic Whether items are topics.
+ */
+ private function migrate_lessons( $user_id, $items, $lp_course_id, $parent_id, $is_topic = false ) {
+ foreach ( $items as $ld_id => $completed ) {
+ $lp_id = get_post_meta( $ld_id, '_lp_lesson_id', true );
+
+ if ( empty( $lp_id ) ) {
+ continue;
+ }
+
+ // Check if already exists using UserLessonModel.
+ $existing = UserLessonModel::find_user_item(
+ $user_id,
+ $lp_id,
+ LP_LESSON_CPT,
+ $lp_course_id,
+ LP_COURSE_CPT,
+ false
+ );
+
+ if ( $existing instanceof UserLessonModel ) {
+ continue;
+ }
+
+ $userLesson = new UserLessonModel();
+ $userLesson->user_id = $user_id;
+ $userLesson->item_id = $lp_id;
+ $userLesson->start_time = current_time( 'mysql' );
+ $userLesson->end_time = $completed ? current_time( 'mysql' ) : null;
+ $userLesson->status = $completed ? 'completed' : 'started';
+ $userLesson->graduation = $completed ? 'passed' : 'in-progress';
+ $userLesson->ref_id = $lp_course_id;
+ $userLesson->parent_id = $parent_id;
+ $userLesson->save();
+ }
+ }
+
+ /**
+ * Migrate quiz attempt.
+ *
+ * @param int $user_id User ID.
+ * @param int $lp_quiz_id LearnPress quiz ID.
+ * @param int $lp_course_id LearnPress course ID.
+ * @param int $parent_id Parent item ID.
+ * @param array $attempt Quiz attempt data.
+ */
+ private function migrate_quiz_attempt( $user_id, $lp_quiz_id, $lp_course_id, $parent_id, $attempt ) {
+ $passed = ! empty( $attempt['pass'] );
+ $time_spent = $attempt['timespent'] ?? 0;
+
+ $userQuiz = new UserQuizModel();
+ $userQuiz->user_id = $user_id;
+ $userQuiz->item_id = $lp_quiz_id;
+ $userQuiz->start_time = isset( $attempt['started'] ) ? gmdate( 'Y-m-d H:i:s', $attempt['started'] ) : current_time( 'mysql' );
+ $userQuiz->end_time = isset( $attempt['completed'] ) ? gmdate( 'Y-m-d H:i:s', $attempt['completed'] ) : current_time( 'mysql' );
+ $userQuiz->status = 'completed';
+ $userQuiz->graduation = $passed ? 'passed' : 'failed';
+ $userQuiz->ref_id = $lp_course_id;
+ $userQuiz->parent_id = $parent_id;
+ $userQuiz->save();
+
+ $user_item_id = $userQuiz->get_user_item_id();
+
+ if ( ! $user_item_id ) {
+ return;
+ }
+
+ // Fetch detailed question statistics from LearnDash.
+ $questions_data = $this->build_questions_result( $attempt, $lp_quiz_id );
+
+ // Calculate question stats.
+ $question_count = count( $questions_data );
+ $question_correct = 0;
+ $question_wrong