Harden published curriculum and student flows
This commit is contained in:
@@ -1,13 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Publishes Grade 10 textbook PDFs into content_assets and publication_bundle_assets
|
||||
* so they appear in the student app under "الكتب المقررة" for each subject.
|
||||
*
|
||||
* Usage:
|
||||
* php backend/scripts/publish_grade10_textbooks.php [--apply]
|
||||
* Link previously reviewed Grade 10 textbook PDFs to published bundles.
|
||||
* Never creates a published bundle, grants rights, or approves an asset.
|
||||
* Usage: php backend/scripts/publish_grade10_textbooks.php [--apply]
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||
@@ -15,165 +12,123 @@ require_once dirname(__DIR__) . '/app/bootstrap.php';
|
||||
use App\Core\Database;
|
||||
|
||||
$apply = in_array('--apply', $argv, true);
|
||||
$projectRoot = dirname(__DIR__, 2);
|
||||
$booksRoot = $projectRoot . '/books';
|
||||
$curriculumRoot = $projectRoot . '/backend/storage/curriculum';
|
||||
|
||||
echo "=== Publishing Grade 10 Textbooks ===\n";
|
||||
$booksRoot = dirname(__DIR__, 2) . '/books';
|
||||
$curriculumRoot = dirname(__DIR__) . '/storage/curriculum';
|
||||
$subjectMap = [
|
||||
'كيمياء' => 'chemistry_10', 'الرياضيات' => 'math_10', 'الفيزياء' => 'physics_10',
|
||||
'الأحياء' => 'biology_10', 'حياتية' => 'biology_10', 'علوم الأرض' => 'earth_sciences_10',
|
||||
'اللغة العربية' => 'arabic_10', 'العربية' => 'arabic_10',
|
||||
'الإنجليزية' => 'english_10', 'الانجليزية' => 'english_10',
|
||||
'التربية الإسلامية' => 'islamic_10', 'الإسلامية' => 'islamic_10',
|
||||
'تاريخ' => 'history_10', 'جغرافيا' => 'geography_10',
|
||||
'الوطنية' => 'civics_10', 'المالية' => 'financial_10',
|
||||
'الرقمية' => 'digital_skills_10', 'حاسوب' => 'digital_skills_10',
|
||||
];
|
||||
|
||||
if (!is_dir($booksRoot)) {
|
||||
echo "Books directory not found at {$booksRoot}\n";
|
||||
fwrite(STDERR, "Books directory is missing.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$pdfFiles = glob($booksRoot . '/*.pdf') ?: [];
|
||||
echo "Found " . count($pdfFiles) . " textbook PDFs in {$booksRoot}.\n";
|
||||
|
||||
$subjectMap = [
|
||||
'كيمياء' => 'chemistry_10',
|
||||
'الرياضيات' => 'math_10',
|
||||
'الفيزياء' => 'physics_10',
|
||||
'الأحياء' => 'biology_10',
|
||||
'حياتية' => 'biology_10',
|
||||
'علوم الأرض' => 'earth_sciences_10',
|
||||
'اللغة العربية' => 'arabic_10',
|
||||
'العربية' => 'arabic_10',
|
||||
'الإنجليزية' => 'english_10',
|
||||
'الانجليزية' => 'english_10',
|
||||
'التربية الإسلامية' => 'islamic_10',
|
||||
'الإسلامية' => 'islamic_10',
|
||||
'تاريخ' => 'history_10',
|
||||
'جغرافيا' => 'geography_10',
|
||||
'الوطنية' => 'civics_10',
|
||||
'المالية' => 'financial_10',
|
||||
'الرقمية' => 'digital_skills_10',
|
||||
'حاسوب' => 'digital_skills_10',
|
||||
];
|
||||
|
||||
$publishedCount = 0;
|
||||
|
||||
foreach ($pdfFiles as $filePath) {
|
||||
$ready = [];
|
||||
$blocked = [];
|
||||
foreach (glob($booksRoot . '/*.pdf') ?: [] as $filePath) {
|
||||
$filename = basename($filePath);
|
||||
$sha256 = hash_file('sha256', $filePath);
|
||||
$bytes = filesize($filePath);
|
||||
|
||||
// Identify subject
|
||||
$matchedSubject = null;
|
||||
foreach ($subjectMap as $keyword => $subKey) {
|
||||
if (str_contains($filename, $keyword)) {
|
||||
$matchedSubject = $subKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$matchedSubject) {
|
||||
echo " - Skipping unclassified PDF: {$filename}\n";
|
||||
if (!str_contains($filename, 'كتاب الطالب')) {
|
||||
echo "SKIP {$filename}: not a student textbook\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$semesterKey = str_contains($filename, 'الثاني') ? 'semester_2' : 'semester_1';
|
||||
echo "\nProcessing: [{$matchedSubject} - {$semesterKey}] {$filename}\n";
|
||||
|
||||
// Destination in curriculum storage
|
||||
$relativeDest = "textbooks/grade_10/{$matchedSubject}/" . basename($filePath);
|
||||
$fullDest = $curriculumRoot . '/' . $relativeDest;
|
||||
|
||||
if ($apply) {
|
||||
$destDir = dirname($fullDest);
|
||||
if (!is_dir($destDir)) {
|
||||
mkdir($destDir, 0755, true);
|
||||
}
|
||||
if (!file_exists($fullDest) || hash_file('sha256', $fullDest) !== $sha256) {
|
||||
copy($filePath, $fullDest);
|
||||
}
|
||||
$subject = null;
|
||||
foreach ($subjectMap as $keyword => $key) {
|
||||
if (str_contains($filename, $keyword)) { $subject = $key; break; }
|
||||
}
|
||||
|
||||
// Find the representative published bundle for this subject
|
||||
$lesson = Database::selectOne(
|
||||
"SELECT cl.id, cl.uuid FROM curriculum_lessons cl
|
||||
JOIN publication_bundles pb ON pb.curriculum_lesson_id = cl.id
|
||||
WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ? AND cl.semester_key = ?
|
||||
ORDER BY cl.unit_key ASC, cl.lesson_key ASC LIMIT 1",
|
||||
[$matchedSubject, $semesterKey]
|
||||
);
|
||||
|
||||
if (!$lesson) {
|
||||
// Fallback: any lesson in the subject
|
||||
$lesson = Database::selectOne(
|
||||
"SELECT cl.id, cl.uuid FROM curriculum_lessons cl
|
||||
WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ?
|
||||
ORDER BY cl.id ASC LIMIT 1",
|
||||
[$matchedSubject]
|
||||
);
|
||||
}
|
||||
|
||||
if (!$lesson) {
|
||||
echo " - No curriculum lesson found for {$matchedSubject}.\n";
|
||||
if ($subject === null) { $blocked[] = "{$filename}: unknown subject"; continue; }
|
||||
if (str_contains($filename, 'الفصل الثاني')) {
|
||||
$semester = 'semester_2';
|
||||
} elseif (str_contains($filename, 'الفصل الأول')) {
|
||||
$semester = 'semester_1';
|
||||
} else {
|
||||
$blocked[] = "{$filename}: explicit semester is missing";
|
||||
continue;
|
||||
}
|
||||
$sha = hash_file('sha256', $filePath);
|
||||
if ($sha === false) { $blocked[] = "{$filename}: unreadable source"; continue; }
|
||||
|
||||
$lessonId = (int)$lesson['id'];
|
||||
|
||||
// Ensure publication bundle exists
|
||||
$bundle = Database::selectOne(
|
||||
"SELECT id FROM publication_bundles WHERE curriculum_lesson_id = ? AND status = 'published' LIMIT 1",
|
||||
[$lessonId]
|
||||
);
|
||||
|
||||
$bundleId = 0;
|
||||
if ($bundle) {
|
||||
$bundleId = (int)$bundle['id'];
|
||||
} elseif ($apply) {
|
||||
$bUuid = 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));
|
||||
$bundleId = (int)Database::insert(
|
||||
"INSERT INTO publication_bundles (uuid, curriculum_lesson_id, bundle_version, status, published_at)
|
||||
VALUES (?, ?, 'grade10-intake-2026-09-10', 'published', NOW())",
|
||||
[$bUuid, $lessonId]
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure content_asset exists
|
||||
$asset = Database::selectOne(
|
||||
"SELECT id, uuid FROM content_assets WHERE sha256 = ? LIMIT 1",
|
||||
[$sha256]
|
||||
"SELECT id, storage_driver, storage_key, sha256, review_status, rights_status, source_reference
|
||||
FROM content_assets WHERE sha256 = ? AND asset_type = 'textbook_pdf' LIMIT 1",
|
||||
[$sha]
|
||||
);
|
||||
|
||||
$assetId = 0;
|
||||
if ($asset) {
|
||||
$assetId = (int)$asset['id'];
|
||||
echo " - Asset exists (ID: {$assetId})\n";
|
||||
if ($apply) {
|
||||
Database::query(
|
||||
"UPDATE content_assets SET review_status = 'approved', rights_status = 'cleared', storage_key = ? WHERE id = ?",
|
||||
[$relativeDest, $assetId]
|
||||
);
|
||||
}
|
||||
} elseif ($apply) {
|
||||
$aUuid = 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));
|
||||
$assetId = (int)Database::insert(
|
||||
"INSERT INTO content_assets
|
||||
(uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, rights_status, review_status)
|
||||
VALUES (?, 'textbook_pdf', 'local', ?, 'application/pdf', ?, ?, 'cleared', 'approved')",
|
||||
[$aUuid, $relativeDest, $bytes, $sha256]
|
||||
);
|
||||
echo " - Created content_asset ID {$assetId}\n";
|
||||
if (!$asset || $asset['review_status'] !== 'approved' || $asset['rights_status'] !== 'cleared' ||
|
||||
trim((string)$asset['source_reference']) === '') {
|
||||
$blocked[] = "{$filename}: reviewed textbook asset and rights evidence are missing";
|
||||
continue;
|
||||
}
|
||||
if ($asset['storage_driver'] !== 'local') {
|
||||
$blocked[] = "{$filename}: verify the remote asset with its storage provider before linking";
|
||||
continue;
|
||||
}
|
||||
$storagePath = realpath($curriculumRoot . '/' . $asset['storage_key']);
|
||||
$rootPath = realpath($curriculumRoot);
|
||||
if ($storagePath === false || $rootPath === false || !str_starts_with($storagePath, $rootPath . DIRECTORY_SEPARATOR) ||
|
||||
!hash_equals($sha, (string)hash_file('sha256', $storagePath))) {
|
||||
$blocked[] = "{$filename}: approved stored file is missing or its checksum differs";
|
||||
continue;
|
||||
}
|
||||
$bundle = Database::selectOne(
|
||||
"SELECT pb.id FROM publication_bundles pb
|
||||
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
||||
WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ? AND cl.semester_key = ?
|
||||
AND cl.source_status = 'approved' AND pb.status = 'published'
|
||||
ORDER BY cl.unit_key, cl.lesson_key, pb.id LIMIT 1",
|
||||
[$subject, $semester]
|
||||
);
|
||||
if (!$bundle) {
|
||||
$blocked[] = "{$filename}: no published bundle for {$subject}/{$semester}";
|
||||
continue;
|
||||
}
|
||||
$ready[] = ['name' => $filename, 'bundle_id' => (int)$bundle['id'], 'asset_id' => (int)$asset['id']];
|
||||
}
|
||||
|
||||
// Link in publication_bundle_assets
|
||||
if ($apply && $bundleId > 0 && $assetId > 0) {
|
||||
foreach ($blocked as $reason) echo "BLOCKED {$reason}\n";
|
||||
foreach ($ready as $row) echo "READY {$row['name']} => bundle {$row['bundle_id']}\n";
|
||||
if (!$apply) {
|
||||
echo "DRY RUN: " . count($ready) . " ready, " . count($blocked) . " blocked.\n";
|
||||
exit($blocked === [] ? 0 : 2);
|
||||
}
|
||||
if ($blocked !== []) {
|
||||
fwrite(STDERR, "REFUSE: resolve all textbook review and matching blockers before --apply.\n");
|
||||
exit(2);
|
||||
}
|
||||
if ($ready === []) {
|
||||
fwrite(STDERR, "REFUSE: no reviewed textbook to link.\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$pdo = Database::getConnection();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
foreach ($ready as $row) {
|
||||
$current = Database::selectOne(
|
||||
"SELECT pb.status, cl.source_status, a.review_status, a.rights_status
|
||||
FROM publication_bundles pb JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
||||
JOIN content_assets a ON a.id = ? WHERE pb.id = ? FOR UPDATE",
|
||||
[$row['asset_id'], $row['bundle_id']]
|
||||
);
|
||||
if (!$current || $current['status'] !== 'published' || $current['source_status'] !== 'approved' ||
|
||||
$current['review_status'] !== 'approved' || $current['rights_status'] !== 'cleared') {
|
||||
throw new RuntimeException('Approval changed while linking textbooks.');
|
||||
}
|
||||
Database::query(
|
||||
"INSERT INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role, sort_order)
|
||||
VALUES (?, ?, 'textbook', 1)
|
||||
ON DUPLICATE KEY UPDATE role = 'textbook'",
|
||||
[$bundleId, $assetId]
|
||||
VALUES (?, ?, 'textbook', 1) ON DUPLICATE KEY UPDATE sort_order = VALUES(sort_order)",
|
||||
[$row['bundle_id'], $row['asset_id']]
|
||||
);
|
||||
echo " - Linked to publication bundle {$bundleId} as textbook.\n";
|
||||
$publishedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($apply) {
|
||||
echo "\n=== Successfully published {$publishedCount} textbooks! ===\n";
|
||||
} else {
|
||||
echo "\nDRY RUN complete. Run with --apply to execute.\n";
|
||||
$pdo->commit();
|
||||
echo "Linked " . count($ready) . " reviewed textbooks.\n";
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
fwrite(STDERR, "FAILED: {$e->getMessage()}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user