Files
saqel/backend/scripts/import_grade10_book_sources.php

172 lines
7.0 KiB
PHP

<?php
declare(strict_types=1);
/**
* Registers supplied Grade 10 textbook PDFs as review-only source assets.
*
* Default mode is read-only. --apply copies PDFs into curriculum storage and
* creates draft, rights-review-required content_assets records. It never
* approves rights, publishes a bundle, or invents curriculum lessons.
*
* Usage:
* php backend/scripts/import_grade10_book_sources.php
* php backend/scripts/import_grade10_book_sources.php --apply
*/
use App\Core\Database;
$apply = in_array('--apply', $argv, true);
$projectRoot = dirname(__DIR__, 2);
$sourceRoot = $projectRoot . '/books';
$curriculumRoot = $projectRoot . '/backend/storage/curriculum';
$manifestPath = $curriculumRoot . '/manifest.json';
if (!is_dir($sourceRoot) || !is_file($manifestPath)) {
fwrite(STDERR, "Books directory or curriculum manifest is unavailable.\n");
exit(1);
}
$manifest = json_decode((string) file_get_contents($manifestPath), true);
if (!is_array($manifest)) {
fwrite(STDERR, "Curriculum manifest is not valid JSON.\n");
exit(1);
}
$knownSemesters = [];
foreach (($manifest['grade_10']['subjects'] ?? []) as $subjectKey => $subject) {
foreach (array_keys($subject['semesters'] ?? []) as $semesterKey) {
$knownSemesters[$subjectKey][$semesterKey] = true;
}
}
$subjectPatterns = [
'arabic_10' => ['اللغة العربية', 'العربية لغتي'],
'english_10' => ['اللغة الإنجليزية'],
'math_10' => ['الرياضيات'],
'physics_10' => ['الفيزياء'],
'chemistry_10' => ['الكيمياء'],
'biology_10' => ['العلوم الحياتية'],
'earth_science_10' => ['علوم الأرض والبيئة'],
'digital_skills_10' => ['المهارات الرقمية'],
'islamic_10' => ['التربية الإسلامية'],
'history_10' => ['التاريخ'],
'geography_10' => ['الجغرافيا'],
'civic_10' => ['التربية الوطنية والمدنية'],
'financial_literacy_10' => ['الثقافة المالية'],
];
$rows = [];
foreach (glob($sourceRoot . '/*.pdf') ?: [] as $sourcePath) {
$filename = basename($sourcePath);
$subjectKey = null;
foreach ($subjectPatterns as $candidate => $patterns) {
foreach ($patterns as $pattern) {
if (str_contains($filename, $pattern)) {
$subjectKey = $candidate;
break 2;
}
}
}
$semesterKey = str_contains($filename, 'الفصل الأول') ? 'semester_1'
: (str_contains($filename, 'الفصل الثاني') ? 'semester_2' : null);
$isInstitutionalBook = str_starts_with($filename, 'كتاب الطالب') || str_starts_with($filename, 'كتاب التمارين');
$sha256 = hash_file('sha256', $sourcePath);
$safeName = preg_replace('/[^A-Za-z0-9._-]+/', '-', pathinfo($filename, PATHINFO_FILENAME)) ?: 'book';
// Arabic filenames normalize to "book" with the ASCII-only fallback;
// append integrity bytes so no two supplied books can overwrite each other.
$storageKey = sprintf('sources/grade_10/%s/%s/%s-%s.pdf', $subjectKey ?: 'unclassified', $semesterKey ?: 'unclassified', trim($safeName, '-'), substr($sha256, 0, 12));
$rows[] = [
'filename' => $filename,
'source_path' => $sourcePath,
'subject_key' => $subjectKey,
'semester_key' => $semesterKey,
'asset_type' => $isInstitutionalBook ? 'textbook_pdf' : 'other',
'pages' => pdfPages($sourcePath),
'byte_size' => filesize($sourcePath),
'sha256' => $sha256,
'storage_key' => $storageKey,
'manifest_scope_exists' => $subjectKey !== null && $semesterKey !== null && isset($knownSemesters[$subjectKey][$semesterKey]),
'intake_status' => !$isInstitutionalBook ? 'manual_rights_and_academic_review_required'
: ($subjectKey === null || $semesterKey === null ? 'unclassified_metadata' : 'ready_for_source_review'),
];
}
usort($rows, static fn(array $a, array $b): int => strcmp($a['filename'], $b['filename']));
$report = [
'mode' => $apply ? 'apply' : 'dry_run',
'policy' => 'Draft source registration only; no publication or rights clearance.',
'books_found' => count($rows),
'manifest_supported_sources' => count(array_filter($rows, static fn(array $row): bool => $row['manifest_scope_exists'])),
'sources_outside_current_manifest' => count(array_filter($rows, static fn(array $row): bool => !$row['manifest_scope_exists'])),
'rows' => $rows,
];
if (!$apply) {
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
exit(0);
}
require_once dirname(__DIR__) . '/app/bootstrap.php';
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
foreach ($rows as $row) {
if ($row['asset_type'] !== 'textbook_pdf' || $row['subject_key'] === null || $row['semester_key'] === null) {
continue;
}
$destination = $curriculumRoot . '/' . $row['storage_key'];
if (!is_dir(dirname($destination)) && !mkdir(dirname($destination), 0750, true) && !is_dir(dirname($destination))) {
throw new RuntimeException('Unable to create textbook storage directory.');
}
if (!is_file($destination)) {
if (!copy($row['source_path'], $destination)) {
throw new RuntimeException('Unable to copy textbook source: ' . $row['filename']);
}
}
if (!hash_equals($row['sha256'], hash_file('sha256', $destination))) {
throw new RuntimeException('Copied textbook checksum mismatch: ' . $row['filename']);
}
Database::query(
"INSERT INTO content_assets (uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, source_reference, rights_status, review_status)
VALUES (?, 'textbook_pdf', 'local', ?, 'application/pdf', ?, ?, ?, 'review_required', 'draft')
ON DUPLICATE KEY UPDATE byte_size=VALUES(byte_size), source_reference=VALUES(source_reference)",
[uuid(), $row['storage_key'], $row['byte_size'], $row['sha256'], 'books/' . $row['filename']]
);
}
$pdo->commit();
$report['registered_draft_sources'] = true;
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, "Source import rolled back: {$e->getMessage()}\n");
exit(1);
}
function pdfPages(string $path): ?int
{
$output = [];
$status = 0;
exec('pdfinfo ' . escapeshellarg($path) . ' 2>/dev/null', $output, $status);
if ($status !== 0) {
return null;
}
foreach ($output as $line) {
if (preg_match('/^Pages:\s+(\d+)$/', $line, $match)) {
return (int) $match[1];
}
}
return null;
}
function uuid(): 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));
}