getMessage()); } } /** * Resolve or create the master system curriculum course. * * Returns a guaranteed valid `course_id` that satisfies the FK constraint on * `lessons.course_id → courses.id`. Called by CurriculumController when * uploading AI/ministry videos without an explicit teacher course. * * @return int Valid course_id * @throws \RuntimeException if no subjects or teachers exist and creation fails */ public static function getOrCreateSystemCourse(): int { // Ensure the column exists before querying it (runtime migration) self::ensureSystemCurriculumColumn(); // 1. Look for existing master system course $existing = Database::selectOne( "SELECT id FROM courses WHERE is_system_curriculum = 1 LIMIT 1" ); if ($existing && !empty($existing['id'])) { return (int)$existing['id']; } // 2. Also try by canonical title in case migration was run after records existed $byTitle = Database::selectOne( "SELECT id FROM courses WHERE title LIKE '%منهاج وزارة التربية%' LIMIT 1" ); if ($byTitle && !empty($byTitle['id'])) { // Stamp it so future lookups are fast Database::query( "UPDATE courses SET is_system_curriculum = 1 WHERE id = ?", [(int)$byTitle['id']] ); return (int)$byTitle['id']; } // 3. Create a new master course — resolve required FK dependencies first // Resolve subject_id (use first available subject or create a placeholder) $subject = Database::selectOne("SELECT id FROM subjects LIMIT 1"); if (!$subject) { $subjectId = Database::insert( "INSERT INTO subjects (name, description) VALUES ('المنهاج الوزاري الأردني', 'مقررات وزارة التربية والتعليم المعتمدة') ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)" ); } else { $subjectId = (int)$subject['id']; } // Resolve teacher_id (use system teacher with id=1 or first available) $teacher = Database::selectOne("SELECT id FROM teachers WHERE id = 1 LIMIT 1"); if (!$teacher) { $teacher = Database::selectOne("SELECT id FROM teachers LIMIT 1"); } if (!$teacher) { $tUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)); $teacherId = Database::insert( "INSERT INTO teachers (uuid, full_name, specialization, is_school_exclusive, is_marketplace_public) VALUES (?, 'منهاج الذكاء الاصطناعي والوزارة', 'المنهاج الوزاري المعتمد', 0, 1)", [$tUuid] ); } else { $teacherId = (int)$teacher['id']; } // Generate stable UUID for the master course $uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) ); $courseId = Database::insert( "INSERT INTO courses (uuid, subject_id, teacher_id, title, description, semester, price_jod, is_published, is_system_curriculum, grade_level, stream) VALUES (?, ?, ?, 'منهاج وزارة التربية والتعليم الأساسي', 'الدورة الرئيسية للمنهاج الوزاري — تُستخدم لرفع وتنظيم فيديوهات الذكاء الاصطناعي التعليمية', 'full_year', 0.00, 1, 1, 'tawjihi_2008', 'scientific')", [$uuid, $subjectId, $teacherId] ); if (!$courseId) { throw new \RuntimeException('فشل إنشاء الدورة الرئيسية للمنهاج — تحقق من وجود records في جداول subjects و teachers'); } error_log("[CurriculumService] Created master system course id={$courseId}"); return (int)$courseId; } private static string $storagePath = __DIR__ . '/../../storage/curriculum'; private static string $manifestFile = __DIR__ . '/../../storage/curriculum/manifest.json'; public static function ensureStorage(): void { if (!is_dir(self::$storagePath)) { mkdir(self::$storagePath, 0777, true); } if (!file_exists(self::$manifestFile)) { file_put_contents(self::$manifestFile, json_encode(new \stdClass(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); } } /** * Get Complete Live Curriculum Tree from Storage Manifest */ public static function getCurriculumTree(): array { self::ensureStorage(); if (!file_exists(self::$manifestFile)) { return []; } $json = file_get_contents(self::$manifestFile); $tree = json_decode($json, true); if (!is_array($tree)) { return []; } // Dynamically enrich manifest nodes with live uploaded videos from MySQL try { $dbLessons = \App\Core\Database::select( "SELECT id, title, curriculum_key, hls_url, duration_seconds FROM lessons WHERE hls_url IS NOT NULL AND hls_url != ''" ); if (!empty($dbLessons)) { $attachVideos = function (&$node) use (&$attachVideos, $dbLessons) { if (!is_array($node)) return; if (isset($node['lessons']) && is_array($node['lessons'])) { foreach ($node['lessons'] as &$lesson) { if (!is_array($lesson)) continue; $lessonId = (string)($lesson['id'] ?? ''); $lessonFile = (string)($lesson['file'] ?? ''); $lessonFileNoExt = preg_replace('/\.md$/i', '', $lessonFile); $lessonTitle = (string)($lesson['title'] ?? ''); foreach ($dbLessons as $dbl) { $currKey = (string)($dbl['curriculum_key'] ?? ''); $currKeyNoExt = preg_replace('/\.md$/i', '', $currKey); $dbTitle = (string)($dbl['title'] ?? ''); $matched = false; if ($currKey !== '' && ($currKey === $lessonFile || $currKeyNoExt === $lessonFileNoExt)) { $matched = true; } elseif ($currKeyNoExt !== '' && str_ends_with($currKeyNoExt, $lessonId)) { $matched = true; } elseif ($dbTitle !== '' && ($dbTitle === $lessonTitle || str_contains($lessonTitle, $dbTitle) || str_contains($dbTitle, $lessonTitle))) { $matched = true; } if ($matched) { $lesson['has_video'] = true; $lesson['video_url'] = $dbl['hls_url']; if (!empty($dbl['duration_seconds'])) { $lesson['duration_seconds'] = (int)$dbl['duration_seconds']; } break; } } } } foreach ($node as &$child) { if (is_array($child)) { $attachVideos($child); } } }; $attachVideos($tree); } } catch (\Throwable $e) { error_log("Enrich curriculum tree notice: " . $e->getMessage()); } return $tree; } /** Resolve a manifest lesson slug to its canonical title/file. */ public static function findLessonById(string $lessonId): ?array { $walk = function ($node) use (&$walk, $lessonId): ?array { if (!is_array($node)) return null; if (isset($node['lessons']) && is_array($node['lessons'])) { foreach ($node['lessons'] as $lesson) { if (is_array($lesson) && (string)($lesson['id'] ?? '') === $lessonId) return $lesson; } } foreach ($node as $value) { $found = $walk($value); if ($found !== null) return $found; } return null; }; return $walk(self::getCurriculumTree()); } /** Resolve a manifest file path to its canonical lesson record. */ public static function findLessonByFile(string $file): ?array { $walk = function ($node) use (&$walk, $file): ?array { if (!is_array($node)) return null; if (isset($node['lessons']) && is_array($node['lessons'])) { foreach ($node['lessons'] as $lesson) { if (is_array($lesson) && (string)($lesson['file'] ?? '') === $file) return $lesson; } } foreach ($node as $value) { $found = $walk($value); if ($found !== null) return $found; } return null; }; return $walk(self::getCurriculumTree()); } /** * Save/Update Complete Curriculum Tree Manifest */ public static function saveCurriculumTree(array $tree): bool { self::ensureStorage(); return file_put_contents(self::$manifestFile, json_encode($tree, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) !== false; } /** * Merge Newly Extracted PDF Structure into Live Tree */ public static function mergeExtractedCurriculum(array $extracted): array { self::ensureStorage(); $tree = self::getCurriculumTree(); $gradeKey = $extracted['grade_key'] ?? 'general_grade'; $gradeName = $extracted['grade_name'] ?? 'الصف الدراسي'; $subKey = $extracted['subject_key'] ?? 'general_subject'; $subName = $extracted['subject_name'] ?? 'المبحث'; $semKey = $extracted['semester_key'] ?? 'semester_1'; $semName = $extracted['semester_name'] ?? 'الفصل الدراسي الأول'; if (!isset($tree[$gradeKey])) { $tree[$gradeKey] = [ 'name' => $gradeName, 'subjects' => [] ]; } if (!isset($tree[$gradeKey]['subjects'][$subKey])) { $tree[$gradeKey]['subjects'][$subKey] = [ 'name' => $subName, 'semesters' => [] ]; } if (!isset($tree[$gradeKey]['subjects'][$subKey]['semesters'][$semKey])) { $tree[$gradeKey]['subjects'][$subKey]['semesters'][$semKey] = [ 'name' => $semName, 'units' => [] ]; } // Add / Update Units & Lessons foreach ($extracted['units'] ?? [] as $unit) { $unitKey = $unit['unit_key'] ?? ('unit_' . mt_rand(100, 999)); $unitName = $unit['unit_name'] ?? 'الوحدة الدراسية'; $unitLessons = []; foreach ($unit['lessons'] ?? [] as $les) { $lesId = $les['lesson_id'] ?? ('les_' . mt_rand(1000, 9999)); $lesTitle = $les['title'] ?? 'الدرس'; $lesOutcomes = $les['outcomes'] ?? []; $relFilePath = "{$gradeKey}/{$subKey}/{$semKey}/{$unitKey}/{$lesId}.md"; $mdContent = $les['markdown_content'] ?? "# {$lesTitle}\n\nنتاجات التعلم المستهدفة للمنهاج الرسمي."; // Save MD file to disk self::saveLessonMarkdown($relFilePath, $mdContent); $unitLessons[] = [ 'id' => $lesId, 'title' => $lesTitle, 'outcomes' => $lesOutcomes, 'file' => $relFilePath ]; } $tree[$gradeKey]['subjects'][$subKey]['semesters'][$semKey]['units'][$unitKey] = [ 'name' => $unitName, 'lessons' => $unitLessons ]; } self::saveCurriculumTree($tree); return $tree; } /** * Save Single Lesson Markdown Content to Hierarchical File System */ public static function saveLessonMarkdown(string $relativePath, string $content): bool { self::ensureStorage(); $fullPath = self::$storagePath . '/' . ltrim($relativePath, '/'); $dir = dirname($fullPath); if (!is_dir($dir)) { mkdir($dir, 0777, true); } return file_put_contents($fullPath, $content) !== false; } /** * Retrieve Lesson Markdown Content */ public static function getLessonMarkdown(string $relativePath): string { self::ensureStorage(); $fullPath = self::$storagePath . '/' . ltrim($relativePath, '/'); if (file_exists($fullPath)) { return file_get_contents($fullPath); } return "# محتوى المنهاج\nالمحتوى المعتمد للمنهاج الرسمي."; } public static function saveLessonAiAssets(string $relativePath, array $assets): bool { self::ensureStorage(); $baseName = preg_replace('/\.md$/i', '', ltrim($relativePath, '/')); $jsonPath = self::$storagePath . '/' . $baseName . '_ai_assets.json'; $dir = dirname($jsonPath); if (!is_dir($dir)) { mkdir($dir, 0777, true); } return file_put_contents($jsonPath, json_encode($assets, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) !== false; } public static function getLessonAiAssets(string $relativePath): array { self::ensureStorage(); $baseName = preg_replace('/\.md$/i', '', ltrim($relativePath, '/')); $jsonPath = self::$storagePath . '/' . $baseName . '_ai_assets.json'; if (file_exists($jsonPath)) { return json_decode(file_get_contents($jsonPath), true) ?: []; } return []; } public static function saveLessonLabData(string $relativePath, array $labData): bool { self::ensureStorage(); $baseName = preg_replace('/\.md$/i', '', ltrim($relativePath, '/')); $jsonPath = self::$storagePath . '/' . $baseName . '_lab.json'; $dir = dirname($jsonPath); if (!is_dir($dir)) { mkdir($dir, 0777, true); } return file_put_contents($jsonPath, json_encode($labData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) !== false; } public static function getLessonLabData(string $relativePath): array { self::ensureStorage(); $baseName = preg_replace('/\.md$/i', '', ltrim($relativePath, '/')); $jsonPath = self::$storagePath . '/' . $baseName . '_lab.json'; if (file_exists($jsonPath)) { return json_decode(file_get_contents($jsonPath), true) ?: []; } return []; } /** * Fast Keyword Search across All Markdown Files */ public static function searchCurriculum(string $query): array { $tree = self::getCurriculumTree(); $results = []; $queryClean = mb_strtolower(trim($query)); if (empty($queryClean)) return []; foreach ($tree as $gradeKey => $grade) { foreach ($grade['subjects'] ?? [] as $subKey => $subject) { foreach ($subject['semesters'] ?? [] as $semKey => $semester) { foreach ($semester['units'] ?? [] as $unitKey => $unit) { foreach ($unit['lessons'] ?? [] as $lesson) { $matches = false; if (str_contains(mb_strtolower($lesson['title']), $queryClean) || str_contains(mb_strtolower($unit['name']), $queryClean) || str_contains(mb_strtolower($subject['name']), $queryClean)) { $matches = true; } if (!$matches && !empty($lesson['outcomes'])) { foreach ($lesson['outcomes'] as $out) { if (str_contains(mb_strtolower($out), $queryClean)) { $matches = true; break; } } } if ($matches) { $results[] = [ 'grade' => $grade['name'], 'subject' => $subject['name'], 'unit' => $unit['name'], 'lesson' => $lesson['title'], 'file_path' => $lesson['file'], 'outcomes' => $lesson['outcomes'] ]; } } } } } } return $results; } /** * Retrieve Context for Video Analysis */ public static function getCurriculumContext(int $courseId, string $lessonTitle): array { $tree = self::getCurriculumTree(); $walk = function ($node, string $subject = 'المبحث الدراسي', string $unit = 'الوحدة التعليمية') use (&$walk, $lessonTitle): ?array { if (!is_array($node)) return null; if (isset($node['semesters']) && isset($node['name'])) { $subject = (string)$node['name']; } if (isset($node['lessons']) && is_array($node['lessons'])) { $unit = (string)($node['name'] ?? $unit); foreach ($node['lessons'] as $lesson) { if (!is_array($lesson)) continue; if ((string)($lesson['title'] ?? '') === $lessonTitle) { return [ 'grade' => 'الصف العاشر', 'subject' => $subject, 'unit' => $unit, 'core_topics' => array_values(array_filter(array_map('strval', $lesson['outcomes'] ?? []))), 'lesson_file' => (string)($lesson['file'] ?? '') ]; } } } foreach ($node as $value) { $found = $walk($value, $subject, $unit); if ($found !== null) return $found; } return null; }; $matched = $walk($tree); if ($matched !== null) return $matched; // Safe generic context when the lesson is not in the manifest. return [ 'grade' => 'المرحلة التعليمية المعتمدة', 'subject' => 'المبحث الدراسي', 'unit' => 'الوحدة التعليمية', 'core_topics' => ['تحليل المفاهيم الأساسية', 'التطبيقات والمسائل النموذجية'] ]; } }