158 lines
6.8 KiB
PHP
158 lines
6.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Builds a review queue from manifest.json for migration 20260909.
|
|
* Default mode is dry-run and never connects to MySQL. --apply creates only
|
|
* unverified lessons, draft bundles, and draft assets; it never publishes.
|
|
*
|
|
* Usage:
|
|
* php backend/scripts/backfill_curriculum_identity.php
|
|
* php backend/scripts/backfill_curriculum_identity.php --apply
|
|
*/
|
|
|
|
$apply = in_array('--apply', $argv, true);
|
|
$manifestPath = dirname(__DIR__) . '/storage/curriculum/manifest.json';
|
|
$storageRoot = realpath(dirname(__DIR__) . '/storage/curriculum');
|
|
|
|
if (!$storageRoot || !is_file($manifestPath)) {
|
|
fwrite(STDERR, "Manifest or curriculum storage is unavailable.\n");
|
|
exit(1);
|
|
}
|
|
|
|
$manifest = json_decode((string)file_get_contents($manifestPath), true);
|
|
if (!is_array($manifest)) {
|
|
fwrite(STDERR, "Manifest is not valid JSON.\n");
|
|
exit(1);
|
|
}
|
|
|
|
$rows = [];
|
|
foreach ($manifest as $gradeKey => $grade) {
|
|
foreach (($grade['subjects'] ?? []) as $subjectKey => $subject) {
|
|
foreach (($subject['semesters'] ?? []) as $semesterKey => $semester) {
|
|
foreach (($semester['units'] ?? []) as $unitKey => $unit) {
|
|
foreach (($unit['lessons'] ?? []) as $lesson) {
|
|
$file = (string)($lesson['file'] ?? '');
|
|
$candidate = realpath($storageRoot . '/' . ltrim($file, '/'));
|
|
$insideStorage = $candidate && str_starts_with(
|
|
$candidate,
|
|
rtrim($storageRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR
|
|
);
|
|
$rows[] = [
|
|
'grade_key' => (string)$gradeKey,
|
|
'subject_key' => (string)$subjectKey,
|
|
'semester_key' => (string)$semesterKey,
|
|
'unit_key' => (string)$unitKey,
|
|
'lesson_key' => (string)($lesson['id'] ?? ''),
|
|
'title' => (string)($lesson['title'] ?? ''),
|
|
'manifest_path' => $file,
|
|
'path' => $insideStorage ? $candidate : null,
|
|
'missing' => !$insideStorage,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$identityCounts = [];
|
|
foreach ($rows as $row) {
|
|
$identity = implode('|', [$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key']]);
|
|
$identityCounts[$identity] = ($identityCounts[$identity] ?? 0) + 1;
|
|
}
|
|
$conflicts = array_keys(array_filter($identityCounts, static fn (int $count): bool => $count > 1));
|
|
$missing = array_values(array_filter($rows, static fn (array $row): bool => $row['missing']));
|
|
|
|
$report = [
|
|
'mode' => $apply ? 'apply' : 'dry_run',
|
|
'curriculum_version' => 'manifest-2026-09-09',
|
|
'lessons_found' => count($rows),
|
|
'missing_assets' => array_map(static fn (array $row): array => [
|
|
'identity' => implode('/', [$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key']]),
|
|
'title' => $row['title'],
|
|
'manifest_path' => $row['manifest_path'] ?: null,
|
|
'reason' => $row['manifest_path'] === '' ? 'missing_manifest_file_reference' : 'file_not_found_in_local_storage',
|
|
], $missing),
|
|
'identity_conflicts' => $conflicts,
|
|
'eligible_for_review_queue' => count($rows) - count($missing),
|
|
];
|
|
|
|
if (!$apply) {
|
|
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
|
exit(($missing || $conflicts) ? 2 : 0);
|
|
}
|
|
|
|
if ($conflicts) {
|
|
fwrite(STDERR, "Conflicting curriculum identities found. Resolve them before --apply.\n");
|
|
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
|
exit(2);
|
|
}
|
|
|
|
require_once dirname(__DIR__) . '/app/bootstrap.php';
|
|
|
|
use App\Core\Database;
|
|
|
|
$pdo = Database::getConnection();
|
|
$pdo->beginTransaction();
|
|
try {
|
|
foreach ($rows as $row) {
|
|
if ($row['missing']) {
|
|
continue;
|
|
}
|
|
$lessonUuid = selfUuid();
|
|
Database::query(
|
|
"INSERT INTO curriculum_lessons
|
|
(uuid, grade_key, subject_key, semester_key, unit_key, lesson_key, curriculum_version, title, source_manifest_path, source_status)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'manifest-2026-09-09', ?, ?, 'unverified')
|
|
ON DUPLICATE KEY UPDATE title = VALUES(title), source_manifest_path = VALUES(source_manifest_path)",
|
|
[$lessonUuid, $row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key'], $row['title'], $row['manifest_path']]
|
|
);
|
|
$lessonId = (int)Database::selectOne(
|
|
"SELECT id FROM curriculum_lessons WHERE grade_key = ? AND subject_key = ? AND semester_key = ? AND unit_key = ? AND lesson_key = ? AND curriculum_version = 'manifest-2026-09-09' LIMIT 1",
|
|
[$row['grade_key'], $row['subject_key'], $row['semester_key'], $row['unit_key'], $row['lesson_key']]
|
|
)['id'];
|
|
Database::query(
|
|
"INSERT INTO publication_bundles (uuid, curriculum_lesson_id, bundle_version, status)
|
|
VALUES (?, ?, 'manifest-2026-09-09', 'draft')
|
|
ON DUPLICATE KEY UPDATE id = id",
|
|
[selfUuid(), $lessonId]
|
|
);
|
|
$bundleId = (int)Database::selectOne(
|
|
"SELECT id FROM publication_bundles WHERE curriculum_lesson_id = ? AND bundle_version = 'manifest-2026-09-09' LIMIT 1",
|
|
[$lessonId]
|
|
)['id'];
|
|
$sha = hash_file('sha256', $row['path']);
|
|
$bytes = filesize($row['path']);
|
|
Database::query(
|
|
"INSERT INTO content_assets (uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, source_reference, review_status)
|
|
VALUES (?, 'lesson_markdown', 'local', ?, 'text/markdown; charset=utf-8', ?, ?, ?, 'draft')
|
|
ON DUPLICATE KEY UPDATE id = id",
|
|
[selfUuid(), $row['manifest_path'], $bytes, $sha, 'manifest.json']
|
|
);
|
|
$assetId = (int)Database::selectOne(
|
|
"SELECT id FROM content_assets WHERE storage_driver = 'local' AND storage_key = ? AND sha256 = ? LIMIT 1",
|
|
[$row['manifest_path'], $sha]
|
|
)['id'];
|
|
Database::query(
|
|
"INSERT IGNORE INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role) VALUES (?, ?, 'primary_lesson')",
|
|
[$bundleId, $assetId]
|
|
);
|
|
}
|
|
$pdo->commit();
|
|
$report['created_review_queue'] = true;
|
|
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
|
} catch (Throwable $e) {
|
|
$pdo->rollBack();
|
|
fwrite(STDERR, "Backfill rolled back: {$e->getMessage()}\n");
|
|
exit(1);
|
|
}
|
|
|
|
function selfUuid(): string
|
|
{
|
|
$bytes = random_bytes(16);
|
|
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
|
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
|
}
|