fix assessment import and exams draft
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 51s
Tests / PHPUnit (push) Failing after 1m26s

This commit is contained in:
root
2026-09-12 02:39:47 -04:00
parent 4b2dd4bdf8
commit ce504c933e
7 changed files with 1397 additions and 109 deletions
+218
View File
@@ -0,0 +1,218 @@
<?php
namespace App\Commands;
use App\Libraries\AssessmentDocxReader;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Config\Database;
use Throwable;
class ImportAssessmentDocx extends BaseCommand
{
protected $group = 'Assessments';
protected $name = 'assessment:import-docx';
protected $description = 'Import new-student assessment answers from a DOCX file.';
protected $usage = 'php spark assessment:import-docx <file.docx> --student-id <id> --form-id <id> [--dry-run] [--overwrite] [--complete]';
protected $arguments = [
'file' => 'Path to the DOCX containing the seeded questions and their answers.',
];
protected $options = [
'--student-id' => 'Required database ID of the student.',
'--form-id' => 'Required assessment form ID.',
'--dry-run' => 'Parse and validate without changing the database.',
'--overwrite' => 'Allow replacement of existing non-empty answers.',
'--complete' => 'Mark the assessment completed; requires all form questions to have answers.',
];
public function run(array $params)
{
$path = trim((string) ($params[0] ?? ''));
$studentId = $this->positiveOption('student-id');
$formId = $this->positiveOption('form-id');
$dryRun = CLI::getOption('dry-run') !== null;
$overwrite = CLI::getOption('overwrite') !== null;
$complete = CLI::getOption('complete') !== null;
if ($path === '' || $studentId === null || $formId === null) {
CLI::error('File, --student-id, and --form-id are required.');
CLI::write($this->usage);
return EXIT_ERROR;
}
try {
$realPath = realpath($path);
if ($realPath === false) {
throw new \RuntimeException("DOCX file does not exist: {$path}");
}
$db = Database::connect();
$this->assertTables($db);
$student = $db->table('students')->select('id, school_id, firstname, lastname')->where('id', $studentId)->get()->getRowArray();
if ($student === null) {
throw new \RuntimeException("Student {$studentId} was not found.");
}
$form = $db->table('assessment_forms f')
->select('f.id, f.name, f.pool_id, f.status, f.school_year, p.name AS pool_name')
->join('question_pools p', 'p.id = f.pool_id')
->where('f.id', $formId)->get()->getRowArray();
if ($form === null) {
throw new \RuntimeException("Assessment form {$formId} was not found.");
}
if ((string) $form['pool_name'] !== 'New Student Assessment') {
throw new \RuntimeException('The selected form does not belong to the New Student Assessment pool.');
}
$questions = $db->table('assessment_form_questions fq')
->select('q.id, q.text, fq.order_index')
->join('assessment_questions q', 'q.id = fq.question_id')
->where('fq.form_id', $formId)
->orderBy('fq.order_index', 'ASC')->get()->getResultArray();
if ($questions === []) {
throw new \RuntimeException('The selected assessment form has no questions.');
}
$parsed = (new AssessmentDocxReader())->read($realPath, $questions);
CLI::write(sprintf(
'Student: #%d %s %s (%s)',
$studentId,
(string) $student['firstname'],
(string) $student['lastname'],
(string) ($student['school_id'] ?? 'no school ID')
));
CLI::write(sprintf('Form: #%d %s [%s]', $formId, (string) $form['name'], (string) ($form['school_year'] ?? '')));
CLI::write(sprintf('Parsed %d document blocks; matched %d of %d questions.', $parsed['block_count'], count($parsed['answers']), count($questions)));
foreach ($questions as $question) {
$questionId = (int) $question['id'];
$length = mb_strlen($parsed['answers'][$questionId] ?? '');
CLI::write(sprintf(' [%s] question_id=%d answer_chars=%d', $length > 0 ? 'matched' : 'missing', $questionId, $length));
}
if ($parsed['answers'] === []) {
throw new \RuntimeException('No answers were matched. Check that the DOCX contains the exact seeded question text.');
}
if ($complete && $parsed['missing'] !== []) {
throw new \RuntimeException('--complete cannot be used while one or more form questions are missing answers.');
}
$assessment = $db->table('student_assessments')->where(['student_id' => $studentId, 'form_id' => $formId])->get()->getRowArray();
$existingAnswers = [];
if ($assessment !== null) {
foreach ($db->table('student_answers')->where('student_assessment_id', (int) $assessment['id'])->get()->getResultArray() as $row) {
$existingAnswers[(int) $row['question_id']] = $row;
}
}
$conflicts = [];
foreach ($parsed['answers'] as $questionId => $answer) {
$old = trim((string) ($existingAnswers[$questionId]['answer_value'] ?? ''));
if ($old !== '' && $old !== $answer) {
$conflicts[] = $questionId;
}
}
if ($conflicts !== [] && $dryRun) {
CLI::write('Dry run found ' . count($conflicts) . ' existing answer(s) that differ; an actual import will require --overwrite.', 'yellow');
} elseif ($conflicts !== [] && ! $overwrite) {
throw new \RuntimeException('Existing answers differ for question ID(s) ' . implode(', ', $conflicts) . '; rerun with --overwrite after reviewing the dry run.');
}
if ($dryRun) {
CLI::write('Dry run complete; no database rows were changed.', 'yellow');
return EXIT_SUCCESS;
}
$now = date('Y-m-d H:i:s');
$db->transBegin();
try {
if ($assessment === null) {
$db->table('student_assessments')->insert([
'form_id' => $formId,
'student_id' => $studentId,
'status' => $complete ? 'completed' : 'in_progress',
'assigned_at' => $now,
'started_at' => $now,
'submitted_at' => $complete ? $now : null,
'created_at' => $now,
'updated_at' => $now,
]);
$assessmentId = (int) $db->insertID();
} else {
$assessmentId = (int) $assessment['id'];
}
foreach ($parsed['answers'] as $questionId => $answer) {
$payload = ['answer_value' => $answer, 'updated_at' => $now];
if (isset($existingAnswers[$questionId])) {
if (trim((string) $existingAnswers[$questionId]['answer_value']) !== $answer) {
$db->table('student_answers')->where('id', (int) $existingAnswers[$questionId]['id'])->update($payload);
}
} else {
$db->table('student_answers')->insert($payload + [
'student_assessment_id' => $assessmentId,
'question_id' => $questionId,
'created_at' => $now,
]);
}
}
if ($assessment !== null) {
$statusUpdate = ['updated_at' => $now];
if ($complete && (string) $assessment['status'] !== 'graded') {
$statusUpdate += ['status' => 'completed', 'submitted_at' => $now];
} elseif ((string) $assessment['status'] === 'not_started') {
$statusUpdate += ['status' => 'in_progress', 'started_at' => $now];
}
$db->table('student_assessments')->where('id', $assessmentId)->update($statusUpdate);
}
if ($db->transStatus() === false) {
throw new \RuntimeException('The database rejected one or more imported rows.');
}
$db->transCommit();
} catch (Throwable $e) {
$db->transRollback();
throw $e;
}
CLI::write(sprintf('Imported %d answer(s) into student assessment #%d.', count($parsed['answers']), $assessmentId), 'green');
if ($parsed['missing'] !== []) {
CLI::write(count($parsed['missing']) . ' unanswered question(s) were left unchanged.', 'yellow');
}
return EXIT_SUCCESS;
} catch (Throwable $e) {
CLI::error($e->getMessage());
return EXIT_ERROR;
}
}
private function positiveOption(string $name): ?int
{
$rawValue = CLI::getOption($name);
// CodeIgniter 4.7 does not split --option=value. Support that common
// spelling as well as its native --option value form.
if ($rawValue === null || $rawValue === true) {
$prefix = '--' . $name . '=';
foreach ($_SERVER['argv'] ?? [] as $argument) {
if (is_string($argument) && str_starts_with($argument, $prefix)) {
$rawValue = substr($argument, strlen($prefix));
break;
}
}
}
$value = filter_var($rawValue, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $value === false ? null : (int) $value;
}
private function assertTables($db): void
{
foreach (['students', 'question_pools', 'assessment_questions', 'assessment_forms', 'assessment_form_questions', 'student_assessments', 'student_answers'] as $table) {
if (! $db->tableExists($table)) {
throw new \RuntimeException("Required table {$table} does not exist; run the migrations first.");
}
}
}
}
+528 -90
View File
@@ -36,6 +36,7 @@ class ExamDraftController extends BaseController
protected string $authorFilenameColumn = 'teacher_filename';
protected string $reviewerIdColumn = 'admin_id';
protected string $reviewerCommentColumn = 'reviewer_comment';
private bool $stirlingPdfUnavailable = false;
// DB status: submitted, accepted, review needed, rejected, canceled, under review, legacy
@@ -369,11 +370,14 @@ class ExamDraftController extends BaseController
public function reviewIndex()
{
$this->syncAcademicContext();
if ($this->reviewerIdColumn !== '') {
$allDrafts = $this->examDraftModel
->select($this->draftSelectColumns())
->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last')
->select('cs.class_section_name, cs.class_id, c.class_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last')
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
->join('classes c', 'c.id = cs.class_id', 'left')
->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left')
->join('users a', 'a.id = exam_drafts.' . $this->reviewerIdColumn, 'left')
->orderBy('exam_drafts.created_at', 'DESC')
@@ -381,57 +385,36 @@ class ExamDraftController extends BaseController
} else {
$allDrafts = $this->examDraftModel
->select($this->draftSelectColumns())
->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, NULL AS admin_first, NULL AS admin_last', false)
->select('cs.class_section_name, cs.class_id, c.class_name, u.firstname AS teacher_first, u.lastname AS teacher_last, NULL AS admin_first, NULL AS admin_last', false)
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
->join('classes c', 'c.id = cs.class_id', 'left')
->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left')
->orderBy('exam_drafts.created_at', 'DESC')
->findAll();
}
if ($this->hasReviewRevisionColumn) {
$allDrafts = $this->groupReviewRevisions($allDrafts);
}
foreach ($allDrafts as &$row) {
if (empty($row['final_pdf_file'])) {
$pdf = $this->ensurePdfExists($row['final_file'] ?? '', pathinfo($row['final_file'] ?? '', PATHINFO_EXTENSION));
if ($pdf !== null) {
$row['final_pdf_file'] = $pdf;
}
$status = strtolower(trim((string) ($row['status'] ?? '')));
if ($status === 'accepted' || $status === 'legacy' || !empty($row['is_legacy'])) {
$this->ensureArchivedPdf($row, false);
}
}
unset($row);
if ($this->hasReviewRevisionColumn) {
$allDrafts = $this->groupReviewRevisions($allDrafts);
}
$classSections = $this->classSectionModel
->select('class_section_id, class_section_name')
->select('class_section_id, class_section_name, class_id')
->orderBy('class_section_name', 'ASC')
->findAll();
// Group legacy uploads (admin-uploaded accepted exams) by class_section for separate tab
$legacyByClass = [];
if ($this->hasIsLegacyColumn) {
// Keep legacy items out of the main submissions list; show them in the legacy tab only.
$drafts = [];
foreach ($allDrafts as $d) {
$isLegacy = !empty($d['is_legacy']);
if ($isLegacy) {
$cid = (int)($d['class_section_id'] ?? 0);
if (!isset($legacyByClass[$cid])) {
$legacyByClass[$cid] = [
'class_section_id' => $cid,
'class_section_name' => $d['class_section_name'] ?? 'Class ' . $cid,
'items' => [],
];
}
$legacyByClass[$cid]['items'][] = $d;
continue;
}
$drafts[] = $d;
}
} else {
// Column missing: keep behavior simple and avoid legacy tab
$drafts = $allDrafts;
}
// The submissions tab follows the selected school year. Accepted submissions
// stay visible in their year and are also copied into the final grade archive.
[$drafts, $archivedExams] = $this->partitionExamDraftsByYear($allDrafts, $this->schoolYear);
$legacyByClass = $this->groupArchivedExamsByGrade($this->latestFinalArchiveRows($archivedExams));
$legacyFlat = [];
foreach ($legacyByClass as $group) {
@@ -473,8 +456,6 @@ class ExamDraftController extends BaseController
$classData['student_count'] = $count;
$visibleClasses[$csId] = $classData;
}
uasort($visibleClasses, static fn ($a, $b): int => strcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? ''));
$classDraftGroups = [];
foreach ($drafts as $draft) {
$cid = (int) ($draft['class_section_id'] ?? 0);
@@ -483,6 +464,21 @@ class ExamDraftController extends BaseController
}
$classDraftGroups[$cid][] = $draft;
}
// Historical enrollment counts may be incomplete. Never hide a valid exam
// submission merely because its old class has no count row for that year.
foreach ($classDraftGroups as $cid => $group) {
if (isset($visibleClasses[$cid])) {
continue;
}
$classData = $classSectionsById[$cid] ?? [
'class_section_id' => $cid,
'class_section_name' => $group[0]['class_section_name'] ?? ('Class ' . $cid),
];
$classData['student_count'] = (int) ($studentCounts[$cid] ?? 0);
$visibleClasses[$cid] = $classData;
}
uasort($visibleClasses, static fn ($a, $b): int => strcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? ''));
$newSubmissionClasses = [];
foreach ($classDraftGroups as $cid => $group) {
foreach ($group as $draft) {
@@ -571,6 +567,10 @@ class ExamDraftController extends BaseController
self::FINAL_UPLOAD_DIR
);
}
if ($pdfName === null) {
return redirect()->back()->withInput()->with('error', 'The legacy exam could not be converted to PDF. Nothing was archived.');
}
$pdfFilename = pathinfo($file->getClientName(), PATHINFO_FILENAME) . '.pdf';
$basePayload = [
$this->authorIdColumn => $adminId, // store under admin user since legacy uploads are admin-only
'semester' => ucfirst(strtolower($semester)),
@@ -578,8 +578,8 @@ class ExamDraftController extends BaseController
'exam_type' => $examType,
'draft_title' => $examType,
'author_comment' => null,
'final_file' => $stored,
'final_filename' => $file->getClientName(),
'final_file' => $pdfName,
'final_filename' => $pdfFilename,
'status' => 'legacy',
'reviewed_at' => utc_now(),
'version' => 1,
@@ -590,7 +590,7 @@ class ExamDraftController extends BaseController
if ($this->hasIsLegacyColumn) {
$basePayload['is_legacy'] = 1;
}
if ($pdfName !== null && $this->hasFinalPdfColumn) {
if ($this->hasFinalPdfColumn) {
$basePayload['final_pdf_file'] = $pdfName;
}
@@ -739,6 +739,13 @@ class ExamDraftController extends BaseController
$newRow['final_file'] = $finalFile;
$newRow['final_filename'] = $finalFilename;
$pdfName = $this->ensurePdfExists($finalFile, $file ? $file->getClientExtension() : null);
if ($status === 'accepted' && $pdfName === null) {
return redirect()->back()->withInput()->with('error', 'The accepted exam could not be converted to PDF. The review was not finalized.');
}
if ($status === 'accepted' && $pdfName !== null) {
$newRow['final_file'] = $pdfName;
$newRow['final_filename'] = pathinfo((string) $finalFilename, PATHINFO_FILENAME) . '.pdf';
}
if ($pdfName !== null && $this->hasFinalPdfColumn) {
$newRow['final_pdf_file'] = $pdfName;
}
@@ -750,8 +757,8 @@ class ExamDraftController extends BaseController
}
return redirect()->back()->with('error', 'Unable to save the review.');
}
if ($status === 'legacy') {
$this->prepareLegacyPdfVersion($update, $draft);
if (in_array($status, ['accepted', 'legacy'], true) && !$this->prepareLegacyPdfVersion($update, $draft)) {
return redirect()->back()->withInput()->with('error', 'The final exam could not be converted to PDF. The review was not finalized.');
}
if ($this->hasIsLegacyColumn) {
$update['is_legacy'] = strtolower($status) === 'legacy' ? 1 : 0;
@@ -1284,41 +1291,47 @@ class ExamDraftController extends BaseController
$reviewRev = (int) ($row['review_revision'] ?? 0);
if ($reviewRev > 0) {
if (!isset($grouped[$key])) {
$grouped[$key] = $row;
$grouped[$key]['_is_review_row'] = true;
$grouped[$key]['review_files'] = [];
$grouped[$key]['final_version'] = null;
$grouped[$key]['_latest_review_comment'] = null;
}
$grouped[$key]['review_files'][] = [
'review_revision' => $reviewRev,
'final_file' => $row['final_file'] ?? null,
'final_filename' => $row['final_filename'] ?? null,
'status' => $row['status'] ?? null,
];
$reviewComment = $row['reviewer_comment'] ?? $row['admin_comments'] ?? null;
if ($reviewComment !== null && $reviewComment !== '') {
$latestComment = $grouped[$key]['_latest_review_comment'] ?? null;
if ($latestComment === null || $reviewRev > (int) ($latestComment['review_revision'] ?? 0)) {
$grouped[$key]['_latest_review_comment'] = [
'review_revision' => $reviewRev,
'comment' => $reviewComment,
];
if (!isset($grouped[$key])) {
$grouped[$key] = $row;
$grouped[$key]['_is_review_row'] = true;
$grouped[$key]['review_files'] = [];
$grouped[$key]['final_version'] = null;
$grouped[$key]['_latest_review_comment'] = null;
$grouped[$key]['_latest_review'] = null;
}
}
if (strtolower((string) ($row['status'] ?? '')) === 'accepted') {
$currentFinal = $grouped[$key]['final_version'] ?? null;
if ($currentFinal === null || $reviewRev > (int) ($currentFinal['review_revision'] ?? 0)) {
$grouped[$key]['final_version'] = [
'review_revision' => $reviewRev,
'final_file' => $row['final_file'] ?? null,
'final_filename' => $row['final_filename'] ?? null,
];
$grouped[$key]['review_files'][] = [
'review_revision' => $reviewRev,
'final_file' => $row['final_file'] ?? null,
'final_filename' => $row['final_filename'] ?? null,
'status' => $row['status'] ?? null,
];
$reviewComment = $row['reviewer_comment'] ?? $row['admin_comments'] ?? null;
if ($reviewComment !== null && $reviewComment !== '') {
$latestComment = $grouped[$key]['_latest_review_comment'] ?? null;
if ($latestComment === null || $reviewRev > (int) ($latestComment['review_revision'] ?? 0)) {
$grouped[$key]['_latest_review_comment'] = [
'review_revision' => $reviewRev,
'comment' => $reviewComment,
];
}
}
$latestReview = $grouped[$key]['_latest_review'] ?? null;
if ($latestReview === null || $reviewRev > (int) ($latestReview['review_revision'] ?? 0)) {
$grouped[$key]['_latest_review'] = $row;
}
if (strtolower((string) ($row['status'] ?? '')) === 'accepted') {
$currentFinal = $grouped[$key]['final_version'] ?? null;
if ($currentFinal === null || $reviewRev > (int) ($currentFinal['review_revision'] ?? 0)) {
$grouped[$key]['final_version'] = [
'review_revision' => $reviewRev,
'final_file' => $row['final_file'] ?? null,
'final_filename' => $row['final_filename'] ?? null,
'final_pdf_file' => $row['final_pdf_file'] ?? null,
];
}
}
continue;
}
continue;
}
if (!isset($grouped[$key])) {
$grouped[$key] = $row;
@@ -1328,9 +1341,13 @@ class ExamDraftController extends BaseController
if (!empty($grouped[$key]['_is_review_row'])) {
$reviewFiles = $grouped[$key]['review_files'] ?? [];
$finalVersion = $grouped[$key]['final_version'] ?? null;
$latestReviewComment = $grouped[$key]['_latest_review_comment'] ?? null;
$latestReview = $grouped[$key]['_latest_review'] ?? null;
$grouped[$key] = $row;
$grouped[$key]['review_files'] = $reviewFiles;
$grouped[$key]['final_version'] = $finalVersion;
$grouped[$key]['_latest_review'] = $latestReview;
if (!empty($latestReviewComment['comment'])) {
$grouped[$key]['reviewer_comment'] = $latestReviewComment['comment'];
}
@@ -1339,24 +1356,190 @@ class ExamDraftController extends BaseController
}
foreach ($grouped as &$row) {
$latestReview = $row['_latest_review'] ?? null;
if (is_array($latestReview)) {
foreach ([
'status',
'acceptance_type',
'reviewed_at',
'reviewer_id',
'admin_id',
'admin_first',
'admin_last',
'review_revision',
] as $field) {
if (array_key_exists($field, $latestReview)) {
$row[$field] = $latestReview[$field];
}
}
}
if (!empty($row['review_files']) && is_array($row['review_files'])) {
usort($row['review_files'], static fn($a, $b) => ($a['review_revision'] ?? 0) <=> ($b['review_revision'] ?? 0));
}
if (!empty($row['final_version']) && is_array($row['final_version'])) {
$row['final_file'] = $row['final_version']['final_file'] ?? $row['final_file'] ?? null;
$row['final_filename'] = $row['final_version']['final_filename'] ?? $row['final_filename'] ?? null;
$row['final_pdf_file'] = $row['final_version']['final_pdf_file'] ?? $row['final_pdf_file'] ?? null;
}
if (empty($row['reviewer_comment']) && !empty($row['_latest_review_comment']['comment'])) {
$row['reviewer_comment'] = $row['_latest_review_comment']['comment'];
}
unset($row['_is_review_row']);
unset($row['_latest_review_comment']);
unset($row['_latest_review']);
}
unset($row);
return array_values($grouped);
}
/**
* @param list<array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
private function groupArchivedExamsByGrade(array $rows): array
{
$groups = [];
foreach ($rows as $row) {
$gradeId = (int) ($row['class_id'] ?? 0);
if ($gradeId <= 0) {
$gradeId = (int) ($row['class_section_id'] ?? 0);
}
$gradeName = trim((string) ($row['class_name'] ?? ''));
if ($gradeName === '') {
$gradeName = preg_replace('/-[A-Z]$/i', '', (string) ($row['class_section_name'] ?? '')) ?: ('Class ' . $gradeId);
}
$normalizedGrade = strtolower($gradeName);
$gradeLabel = in_array($normalizedGrade, ['kg', 'youth', 'arabic'], true)
? $gradeName
: 'Grade ' . $gradeName;
if (!isset($groups[$gradeId])) {
$groups[$gradeId] = [
'class_id' => $gradeId,
'class_section_name' => $gradeLabel,
'items' => [],
'_item_indexes' => [],
];
}
$fileKey = (string) ($row['final_file'] ?? $row['teacher_file'] ?? '');
if ($fileKey === '') {
$fileKey = 'row:' . (int) ($row['id'] ?? 0);
}
$itemKey = implode('|', [
$fileKey,
(string) ($row['school_year'] ?? ''),
strtolower((string) ($row['semester'] ?? '')),
strtolower((string) ($row['exam_type'] ?? '')),
(int) ($row['version'] ?? 0),
]);
$sectionName = trim((string) ($row['class_section_name'] ?? ''));
if (isset($groups[$gradeId]['_item_indexes'][$itemKey])) {
$index = $groups[$gradeId]['_item_indexes'][$itemKey];
if ($sectionName !== '' && !in_array($sectionName, $groups[$gradeId]['items'][$index]['class_section_names'], true)) {
$groups[$gradeId]['items'][$index]['class_section_names'][] = $sectionName;
}
continue;
}
$row['class_section_names'] = $sectionName !== '' ? [$sectionName] : [];
$groups[$gradeId]['_item_indexes'][$itemKey] = count($groups[$gradeId]['items']);
$groups[$gradeId]['items'][] = $row;
}
foreach ($groups as &$group) {
unset($group['_item_indexes']);
usort($group['items'], static function (array $a, array $b): int {
$yearCompare = strnatcasecmp((string) ($b['school_year'] ?? ''), (string) ($a['school_year'] ?? ''));
if ($yearCompare !== 0) {
return $yearCompare;
}
return strcmp((string) ($b['reviewed_at'] ?? $b['created_at'] ?? ''), (string) ($a['reviewed_at'] ?? $a['created_at'] ?? ''));
});
}
unset($group);
uasort($groups, static fn (array $a, array $b): int => strnatcasecmp(
(string) ($a['class_section_name'] ?? ''),
(string) ($b['class_section_name'] ?? '')
));
return $groups;
}
/**
* @param list<array<string,mixed>> $rows
* @return array{0:list<array<string,mixed>>,1:list<array<string,mixed>>}
*/
private function partitionExamDraftsByYear(array $rows, string $schoolYear): array
{
$drafts = [];
$archived = [];
foreach ($rows as $row) {
$status = strtolower(trim((string) ($row['status'] ?? '')));
$isLegacy = $this->hasIsLegacyColumn && !empty($row['is_legacy']);
if ($isLegacy || $status === 'legacy') {
$archived[] = $row;
continue;
}
if ($schoolYear === '' || (string) ($row['school_year'] ?? '') === $schoolYear) {
$drafts[] = $row;
}
if ($status === 'accepted') {
$archived[] = $row;
}
}
return [$drafts, $archived];
}
/**
* Keep all manually archived records, plus only the newest accepted teacher
* version for each class/year/semester/exam line.
*
* @param list<array<string,mixed>> $rows
* @return list<array<string,mixed>>
*/
private function latestFinalArchiveRows(array $rows): array
{
$legacy = [];
$latestAccepted = [];
foreach ($rows as $row) {
$status = strtolower(trim((string) ($row['status'] ?? '')));
$isLegacy = ($this->hasIsLegacyColumn && !empty($row['is_legacy'])) || $status === 'legacy';
if ($isLegacy) {
$legacy[] = $row;
continue;
}
if ($status !== 'accepted') {
continue;
}
$key = $this->examDraftLineKey($row);
$candidateRank = [
(int) ($row['version'] ?? 0),
(int) ($row['review_revision'] ?? 0),
(string) ($row['reviewed_at'] ?? $row['updated_at'] ?? ''),
(int) ($row['id'] ?? 0),
];
$currentRank = $latestAccepted[$key]['rank'] ?? null;
if ($currentRank === null || $candidateRank > $currentRank) {
$latestAccepted[$key] = ['rank' => $candidateRank, 'row' => $row];
}
}
return array_merge(
$legacy,
array_values(array_map(static fn (array $item): array => $item['row'], $latestAccepted))
);
}
private function notifyExamDraftEvent(?array $draft, string $event): void
{
if (empty($draft)) {
@@ -1441,20 +1624,168 @@ class ExamDraftController extends BaseController
}
$base = pathinfo($sourcePath, PATHINFO_FILENAME);
$targetPath = $targetDir . '/' . $base . '.pdf';
$targetPath = $targetDir . '/' . $base . '.archive.pdf';
if (is_file($targetPath)) {
return basename($targetPath);
}
if ($this->convertWithStirlingPdf($sourcePath, $targetPath)) {
return basename($targetPath);
}
if (!function_exists('exec')) {
log_message('warning', 'ExamDraftController::convertDocToPdf skipped because exec() is unavailable.');
return is_file($targetPath) ? basename($targetPath) : null;
}
// Attempt conversion via LibreOffice if available
$cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
@\exec($cmd);
$officeBinary = $this->officeConverterBinary();
if ($officeBinary === null) {
log_message('warning', 'ExamDraftController::convertDocToPdf requires LibreOffice for formatting-safe conversion.');
return null;
}
$conversionToken = bin2hex(random_bytes(6));
$conversionDir = $targetDir . '/lo_' . $conversionToken;
$profileDir = $targetDir . '/lo_profile_' . $conversionToken;
if (!mkdir($conversionDir, 0755, true) && !is_dir($conversionDir)) {
return null;
}
if (!mkdir($profileDir, 0755, true) && !is_dir($profileDir)) {
@rmdir($conversionDir);
return null;
}
$cmd = escapeshellarg($officeBinary)
. ' ' . escapeshellarg('-env:UserInstallation=file://' . $profileDir)
. ' --headless --nologo --convert-to pdf --outdir '
. escapeshellarg($conversionDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
$commandOutput = [];
$exitCode = 1;
@\exec($cmd, $commandOutput, $exitCode);
$convertedPath = $conversionDir . '/' . $base . '.pdf';
if ($exitCode === 0 && is_file($convertedPath) && filesize($convertedPath) > 0) {
@rename($convertedPath, $targetPath);
}
$this->removeConversionDirectory($conversionDir, $targetDir);
$this->removeConversionDirectory($profileDir, $targetDir);
return is_file($targetPath) ? basename($targetPath) : null;
}
private function convertWithStirlingPdf(string $sourcePath, string $targetPath): bool
{
if ($this->stirlingPdfUnavailable || !function_exists('curl_init')) {
return false;
}
$baseUrl = rtrim((string) (env('STIRLING_PDF_URL') ?: ''), '/');
if ($baseUrl === '') {
$this->stirlingPdfUnavailable = true;
return false;
}
$parts = parse_url($baseUrl);
if (!is_array($parts)
|| !in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)
|| empty($parts['host'])) {
$this->stirlingPdfUnavailable = true;
log_message('error', 'STIRLING_PDF_URL is invalid.');
return false;
}
$curl = curl_init($baseUrl . '/api/v1/convert/file/pdf');
if ($curl === false) {
$this->stirlingPdfUnavailable = true;
return false;
}
$headers = ['Accept: application/pdf'];
$apiKey = trim((string) (env('STIRLING_PDF_API_KEY') ?: ''));
if ($apiKey !== '') {
$headers[] = 'X-API-KEY: ' . $apiKey;
}
$mimeType = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION)) === 'doc'
? 'application/msword'
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'fileInput' => new \CURLFile(
$sourcePath,
$mimeType,
basename($sourcePath)
),
],
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 120,
]);
$body = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if (!is_string($body) || $status !== 200 || !str_starts_with($body, '%PDF') || strlen($body) < 100) {
$this->stirlingPdfUnavailable = true;
log_message('warning', 'Stirling-PDF conversion failed with HTTP {status}: {error}', [
'status' => $status,
'error' => $error,
]);
return false;
}
$temporaryPath = $targetPath . '.tmp-' . bin2hex(random_bytes(4));
if (file_put_contents($temporaryPath, $body, LOCK_EX) === false) {
return false;
}
if (!@rename($temporaryPath, $targetPath)) {
@unlink($temporaryPath);
return false;
}
return is_file($targetPath) && filesize($targetPath) > 0;
}
private function removeConversionDirectory(string $directory, string $allowedParent): void
{
$normalizedParent = rtrim(realpath($allowedParent) ?: $allowedParent, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$normalizedDirectory = realpath($directory);
if ($normalizedDirectory === false || !str_starts_with($normalizedDirectory . DIRECTORY_SEPARATOR, $normalizedParent)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($normalizedDirectory, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
}
@rmdir($normalizedDirectory);
}
private function officeConverterBinary(): ?string
{
$configured = trim((string) (env('EXAM_PDF_CONVERTER_BINARY') ?: env('exam.pdf_converter_binary') ?: ''));
$candidates = array_values(array_unique(array_filter([
$configured,
'/usr/bin/soffice',
'/usr/local/bin/soffice',
'/opt/homebrew/bin/soffice',
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
])));
foreach ($candidates as $candidate) {
if (is_file($candidate) && is_executable($candidate)) {
return $candidate;
}
}
return null;
}
private function schemaHasColumn(string $table, string $column): bool
{
try {
@@ -1477,28 +1808,134 @@ class ExamDraftController extends BaseController
$path = $this->fullUploadPath($subdir, $filename);
$base = pathinfo($path, PATHINFO_FILENAME);
$dir = pathinfo($path, PATHINFO_DIRNAME);
$pdfPath = $dir . '/' . $base . '.pdf';
$pdfPath = $dir . '/' . $base . '.archive.pdf';
return is_file($pdfPath) ? basename($pdfPath) : null;
}
private function ensurePdfExists(string $finalFilename, ?string $originalExt): ?string
{
$ext = strtolower((string)$originalExt);
if ($ext === 'pdf') {
return $this->isFormattingSafePdf($finalFilename) ? $finalFilename : null;
}
$pdfNeighbor = $this->neighborPdfIfExists($finalFilename, self::FINAL_UPLOAD_DIR);
if ($pdfNeighbor !== null) {
return $pdfNeighbor;
}
$ext = strtolower((string)$originalExt);
if ($ext === 'pdf') {
// final file itself is already pdf
$path = $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename);
return is_file($path) ? $finalFilename : null;
}
return $this->convertDocToPdf(
$this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename),
self::FINAL_UPLOAD_DIR
);
}
/** @param array<string,mixed> $row */
private function ensureArchivedPdf(array &$row, bool $allowConversion = true): void
{
$existingPdf = trim((string) ($row['final_pdf_file'] ?? ''));
if ($existingPdf !== '' && $this->isFormattingSafePdf($existingPdf)) {
return;
}
$updates = [];
$finalFile = trim((string) ($row['final_file'] ?? ''));
if ($existingPdf !== '' && ($sourceFile = $this->sourceDocumentBesidePdf($existingPdf)) !== null) {
$sourceExt = strtolower(pathinfo($sourceFile, PATHINFO_EXTENSION));
$displayBase = pathinfo((string) ($row['final_filename'] ?? $sourceFile), PATHINFO_FILENAME);
$finalFile = $sourceFile;
$row['final_file'] = $sourceFile;
$row['final_filename'] = $displayBase . '.' . $sourceExt;
$row['final_pdf_file'] = null;
$updates['final_file'] = $sourceFile;
$updates['final_filename'] = $row['final_filename'];
if ($this->hasFinalPdfColumn) {
$updates['final_pdf_file'] = null;
}
}
if (!$allowConversion) {
$id = (int) ($row['id'] ?? 0);
if ($id > 0 && $updates !== []) {
$this->examDraftModel->update($id, $updates);
}
return;
}
if ($finalFile === '') {
$teacherFile = $this->draftTeacherFile($row);
if (!empty($teacherFile)) {
$teacherExt = strtolower(pathinfo($teacherFile, PATHINFO_EXTENSION));
if (in_array($teacherExt, ['doc', 'docx'], true) && $this->officeConverterBinary() === null) {
return;
}
$finalFile = (string) ($this->copyDraftToFinal($teacherFile) ?? '');
if ($finalFile !== '') {
$updates['final_file'] = $finalFile;
$updates['final_filename'] = $this->draftTeacherFilename($row) ?? $teacherFile;
$row['final_file'] = $finalFile;
$row['final_filename'] = $updates['final_filename'];
}
}
}
if ($finalFile === '') {
return;
}
$pdfName = $this->ensurePdfExists($finalFile, pathinfo($finalFile, PATHINFO_EXTENSION));
if ($pdfName === null) {
$id = (int) ($row['id'] ?? 0);
if ($id > 0 && $updates !== []) {
$this->examDraftModel->update($id, $updates);
}
log_message('warning', 'Unable to create archived exam PDF for draft id {id}.', [
'id' => $id,
]);
return;
}
$row['final_pdf_file'] = $pdfName;
$pdfFilename = pathinfo((string) ($row['final_filename'] ?? $row['teacher_filename'] ?? $pdfName), PATHINFO_FILENAME) . '.pdf';
$row['final_file'] = $pdfName;
$row['final_filename'] = $pdfFilename;
$updates['final_file'] = $pdfName;
$updates['final_filename'] = $pdfFilename;
if ($this->hasFinalPdfColumn) {
$updates['final_pdf_file'] = $pdfName;
}
$id = (int) ($row['id'] ?? 0);
if ($id > 0 && $updates !== []) {
$this->examDraftModel->update($id, $updates);
}
}
private function isFormattingSafePdf(string $filename): bool
{
if (strtolower(pathinfo($filename, PATHINFO_EXTENSION)) !== 'pdf') {
return false;
}
$path = $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $filename);
if (!is_file($path)) {
return false;
}
if (str_ends_with(strtolower($filename), '.archive.pdf')) {
return true;
}
// A PDF sharing its generated storage name with a DOC/DOCX is output
// from the removed lossy converter. A directly uploaded PDF has no such
// neighboring source document and is safe to archive.
return $this->sourceDocumentBesidePdf($filename) === null;
}
private function sourceDocumentBesidePdf(string $filename): ?string
{
$base = pathinfo($filename, PATHINFO_FILENAME);
foreach (['docx', 'doc'] as $extension) {
$candidate = $base . '.' . $extension;
if (is_file($this->fullUploadPath(self::FINAL_UPLOAD_DIR, $candidate))) {
return $candidate;
}
}
return null;
}
private function normalizeExamType($value): string
{
return trim((string) $value);
@@ -1523,7 +1960,7 @@ class ExamDraftController extends BaseController
return $destName;
}
private function prepareLegacyPdfVersion(array &$update, array $draft): void
private function prepareLegacyPdfVersion(array &$update, array $draft): bool
{
$finalFile = $update['final_file'] ?? $draft['final_file'] ?? null;
if (empty($finalFile)) {
@@ -1538,12 +1975,12 @@ class ExamDraftController extends BaseController
}
}
if (empty($finalFile)) {
return;
return false;
}
$ext = strtolower(pathinfo($finalFile, PATHINFO_EXTENSION));
$pdfName = $this->ensurePdfExists($finalFile, $ext);
if ($pdfName === null) {
return;
return false;
}
$filename = $update['final_filename'] ?? $draft['final_filename'] ?? '';
$baseName = '';
@@ -1561,5 +1998,6 @@ class ExamDraftController extends BaseController
if ($this->hasFinalPdfColumn) {
$update['final_pdf_file'] = $pdfName;
}
return true;
}
}
+27 -8
View File
@@ -4,6 +4,7 @@ namespace App\Controllers\View;
use CodeIgniter\Controller;
use CodeIgniter\Exceptions\PageNotFoundException;
use Config\Database;
class FilesController extends Controller
{
@@ -287,11 +288,20 @@ class FilesController extends Controller
private function buildDraftDownloadName(string $filename, string $subdir): string
{
$db = Database::connect();
$column = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
$row = $db->table('exam_drafts ed')
$builder = $db->table('exam_drafts ed')
->select('ed.version, ed.exam_type, ed.class_section_id, cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left')
->where('ed.' . $column, $filename)
->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left');
if ($subdir === 'finals') {
$builder->groupStart()
->where('ed.final_file', $filename);
if ($db->fieldExists('final_pdf_file', 'exam_drafts')) {
$builder->orWhere('ed.final_pdf_file', $filename);
}
$builder->groupEnd();
} else {
$builder->where('ed.' . $this->resolveExamDraftFileColumn($db), $filename);
}
$row = $builder
->limit(1)
->get()
->getRowArray();
@@ -363,12 +373,21 @@ class FilesController extends Controller
}
$db = Database::connect();
$fileColumn = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
$authorIdColumn = $this->resolveExamDraftAuthorIdColumn($db);
$draft = $db->table('exam_drafts ed')
->select('ed.class_section_id, ed.school_year, ed.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id')
->where('ed.' . $fileColumn, $filename)
$builder = $db->table('exam_drafts ed')
->select('ed.class_section_id, ed.school_year, ed.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id');
if ($subdir === 'finals') {
$builder->groupStart()
->where('ed.final_file', $filename);
if ($db->fieldExists('final_pdf_file', 'exam_drafts')) {
$builder->orWhere('ed.final_pdf_file', $filename);
}
$builder->groupEnd();
} else {
$builder->where('ed.' . $this->resolveExamDraftFileColumn($db), $filename);
}
$draft = $builder
->limit(1)
->get()
->getRowArray();
+308
View File
@@ -0,0 +1,308 @@
<?php
namespace App\Libraries;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;
use RuntimeException;
use ZipArchive;
/**
* Extracts question/answer pairs from a Word document without executing macros
* or importing embedded content.
*/
class AssessmentDocxReader
{
/**
* @param array<int, array{id:mixed, text:mixed}> $questions
* @return array{answers: array<int, string>, missing: array<int, string>, block_count: int}
*/
public function read(string $path, array $questions): array
{
return $this->mapAnswers($this->extractBlocks($path), $questions);
}
/**
* @return list<string>
*/
public function extractBlocks(string $path): array
{
if (! is_file($path) || ! is_readable($path)) {
throw new RuntimeException("DOCX file is not readable: {$path}");
}
if (strtolower((string) pathinfo($path, PATHINFO_EXTENSION)) !== 'docx') {
throw new RuntimeException('The input file must have a .docx extension.');
}
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException('The input is not a readable DOCX archive.');
}
try {
$xml = $zip->getFromName('word/document.xml');
} finally {
$zip->close();
}
if (! is_string($xml) || $xml === '') {
throw new RuntimeException('The DOCX does not contain word/document.xml.');
}
$previous = libxml_use_internal_errors(true);
try {
$document = new DOMDocument();
if (! $document->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) {
throw new RuntimeException('The DOCX document XML is invalid.');
}
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
$xpath = new DOMXPath($document);
$xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
$body = $xpath->query('/w:document/w:body')->item(0);
if (! $body instanceof DOMElement) {
throw new RuntimeException('The DOCX has no document body.');
}
$blocks = [];
foreach ($body->childNodes as $node) {
if (! $node instanceof DOMElement) {
continue;
}
if ($node->localName === 'p') {
$this->appendBlock($blocks, $this->nodeText($xpath, $node));
continue;
}
if ($node->localName !== 'tbl') {
continue;
}
foreach ($xpath->query('.//w:tr/w:tc', $node) as $cell) {
$paragraphs = [];
foreach ($xpath->query('./w:p', $cell) as $paragraph) {
$text = trim($this->nodeText($xpath, $paragraph));
if ($text !== '') {
$paragraphs[] = $text;
}
}
$this->appendBlock($blocks, implode("\n", $paragraphs));
}
}
return $blocks;
}
/**
* @param list<string> $blocks
* @param array<int, array{id:mixed, text:mixed}> $questions
* @return array{answers: array<int, string>, missing: array<int, string>, block_count: int}
*/
public function mapAnswers(array $blocks, array $questions): array
{
$indexedQuestions = [];
foreach ($questions as $question) {
$id = (int) ($question['id'] ?? 0);
$text = trim((string) ($question['text'] ?? ''));
if ($id > 0 && $text !== '') {
$indexedQuestions[$id] = [
'text' => $text,
'tokens' => $this->tokens($text),
];
}
}
$answerParts = [];
$currentQuestionId = null;
foreach ($blocks as $block) {
$block = $this->cleanText($block);
if ($block === '') {
continue;
}
if ($currentQuestionId !== null && $this->isTerminalHeading($block)) {
break;
}
$match = $this->questionAtStart($block, $indexedQuestions);
if ($match !== null) {
$currentQuestionId = $match['id'];
$answerParts[$currentQuestionId] ??= [];
if ($match['answer'] !== '') {
$answerParts[$currentQuestionId][] = $match['answer'];
}
continue;
}
if ($currentQuestionId !== null) {
$answerParts[$currentQuestionId][] = $block;
}
}
$answers = [];
$missing = [];
foreach ($indexedQuestions as $id => $question) {
$answer = trim(implode("\n", $answerParts[$id] ?? []));
if ($answer === '') {
$missing[$id] = $question['text'];
} else {
$answers[$id] = $answer;
}
}
return ['answers' => $answers, 'missing' => $missing, 'block_count' => count($blocks)];
}
private function nodeText(DOMXPath $xpath, DOMNode $node): string
{
$text = '';
foreach ($xpath->query('.//w:t | .//w:tab | .//w:br | .//w:cr', $node) as $part) {
$text .= match ($part->localName) {
'tab' => "\t",
'br', 'cr' => "\n",
default => $part->textContent,
};
}
return $text;
}
/** @param list<string> $blocks */
private function appendBlock(array &$blocks, string $text): void
{
$text = $this->cleanText($text);
if ($text !== '') {
$blocks[] = $text;
}
}
private function cleanText(string $text): string
{
$text = str_replace(["\u{00A0}", "\r\n", "\r"], [' ', "\n", "\n"], $text);
$lines = preg_split('/\n/u', $text) ?: [];
$lines = array_map(static fn (string $line): string => trim((string) preg_replace('/[\t ]+/u', ' ', $line)), $lines);
return trim(implode("\n", array_filter($lines, static fn (string $line): bool => $line !== '')));
}
/**
* @param array<int, array{text:string, tokens:list<string>}> $questions
* @return array{id:int, answer:string}|null
*/
private function questionAtStart(string $block, array $questions): ?array
{
preg_match_all('/[\p{L}\p{N}]+(?:[\'\x{2019}][\p{L}\p{N}]+)*/u', $block, $matches, PREG_OFFSET_CAPTURE);
$blockTokens = $matches[0] ?? [];
if ($blockTokens === []) {
return null;
}
$start = 0;
if (isset($blockTokens[0]) && strtolower($blockTokens[0][0]) === 'question') {
$start = 1;
}
if (isset($blockTokens[$start]) && preg_match('/^\d+$/', $blockTokens[$start][0]) === 1) {
$start++;
}
$best = null;
foreach ($questions as $id => $question) {
$questionTokens = $question['tokens'];
if ($questionTokens === []) {
continue;
}
$lastMatchedIndex = $this->questionPrefixEnd($blockTokens, $start, $questionTokens);
if ($lastMatchedIndex === null) {
continue;
}
$lastToken = $blockTokens[$lastMatchedIndex];
$answerOffset = $lastToken[1] + strlen($lastToken[0]);
$answer = preg_replace('/^[\s?!.,:;\-\x{2013}\x{2014}\)\]]+/u', '', substr($block, $answerOffset));
$candidate = ['id' => (int) $id, 'answer' => trim((string) $answer), 'length' => count($questionTokens)];
if ($best === null || $candidate['length'] > $best['length']) {
$best = $candidate;
}
}
if ($best === null) {
return null;
}
unset($best['length']);
return $best;
}
/**
* Allows one accidentally omitted word in shorter questions and two in
* long questions while still requiring at least a 90% token match.
*
* @param array<int, array{0:string, 1:int}> $blockTokens
* @param list<string> $questionTokens
*/
private function questionPrefixEnd(array $blockTokens, int $start, array $questionTokens): ?int
{
if (! isset($blockTokens[$start]) || $this->normalizeToken($blockTokens[$start][0]) !== $questionTokens[0]) {
return null;
}
$blockIndex = $start;
$matched = 0;
$omitted = 0;
$lastMatchedIndex = null;
$allowedOmissions = count($questionTokens) >= 16 ? 2 : 1;
foreach ($questionTokens as $expected) {
if (isset($blockTokens[$blockIndex]) && $this->normalizeToken($blockTokens[$blockIndex][0]) === $expected) {
$lastMatchedIndex = $blockIndex;
$blockIndex++;
$matched++;
continue;
}
$omitted++;
if ($omitted > $allowedOmissions) {
return null;
}
}
if ($lastMatchedIndex === null || $matched < 6 || ($matched / count($questionTokens)) < 0.9) {
return null;
}
return $lastMatchedIndex;
}
private function isTerminalHeading(string $block): bool
{
$normalized = implode(' ', $this->tokens($block));
return in_array($normalized, [
'principal notes',
'interviewer notes',
'assessment notes',
'education committee notes',
'final admission decision',
], true);
}
/** @return list<string> */
private function tokens(string $text): array
{
preg_match_all('/[\p{L}\p{N}]+(?:[\'\x{2019}][\p{L}\p{N}]+)*/u', $text, $matches);
return array_map(fn (string $token): string => $this->normalizeToken($token), $matches[0] ?? []);
}
private function normalizeToken(string $token): string
{
return mb_strtolower(str_replace("\u{2019}", "'", $token));
}
}
+34 -11
View File
@@ -24,6 +24,7 @@ $renderBadge = static function (string $status, array $badges): string {
};
$fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensions));
$legacyFileAccept = $fileAccept;
?>
<div class="container-fluid px-4 py-4">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
@@ -48,12 +49,12 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<ul class="nav nav-pills mb-3" id="examDraftTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="submissions-tab" data-bs-toggle="pill" data-bs-target="#submissions" type="button" role="tab" aria-controls="submissions" aria-selected="true">
Submissions
<?= esc($schoolYear ?: 'Current year') ?> submissions
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="legacy-tab" data-bs-toggle="pill" data-bs-target="#legacy" type="button" role="tab" aria-controls="legacy" aria-selected="false">
Legacy exams
Final &amp; legacy exams
</button>
</li>
</ul>
@@ -62,6 +63,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<div class="tab-pane fade show active" id="submissions" role="tabpanel" aria-labelledby="submissions-tab">
<div class="card mb-4">
<div class="card-body">
<p class="text-muted small mb-3">All teacher submissions for the selected school year are shown here, including accepted exams.</p>
<?php if (empty($visibleClasses)): ?>
<p class="text-muted mb-0">No classes with enrolled students are available for this term.</p>
<?php else: ?>
@@ -204,6 +206,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<div class="mt-2">
<?php $fileInputId = 'review_file_' . (int) ($draft['id'] ?? 0); ?>
<input type="file" name="final_file" id="<?= esc($fileInputId) ?>" class="form-control form-control-sm" accept="<?= esc($fileAccept) ?>" form="<?= esc($formId) ?>">
<div class="form-text">Accepted DOC/DOCX files are converted to PDF with Stirling-PDF; uploaded PDFs are preserved as-is.</div>
<button type="submit" class="btn btn-sm btn-outline-primary mt-2" form="<?= esc($formId) ?>">Upload review file</button>
</div>
</td>
@@ -274,7 +277,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div>
<strong>Upload old / legacy exam</strong>
<div class="small text-muted">Store historic exams as accepted records.</div>
<div class="small text-muted">Store historic exams in the grade-based final archive.</div>
</div>
<span class="badge bg-primary-subtle text-primary">Admin only</span>
</div>
@@ -309,8 +312,8 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
</div>
<div class="col-12">
<label class="form-label small">Upload file</label>
<input type="file" name="old_exam_file" class="form-control" accept="<?= esc($fileAccept) ?>" required>
<div class="form-text">Allowed: <?= esc(implode(', ', $allowedExtensions)) ?> • Max <?= number_format($maxUploadBytes / 1024 / 1024, 0) ?> MB</div>
<input type="file" name="old_exam_file" class="form-control" accept="<?= esc($legacyFileAccept) ?>" required>
<div class="form-text">DOC/DOCX files are converted with Stirling-PDF and saved in the archive as PDF • Max <?= number_format($maxUploadBytes / 1024 / 1024, 0) ?> MB</div>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Upload legacy exam</button>
@@ -322,14 +325,20 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<div class="card mb-4">
<div class="card-body">
<?php if (empty($legacyByClass)): ?>
<p class="text-muted mb-0">No legacy exams uploaded yet.</p>
<p class="text-muted mb-0">No final or legacy exams are available yet.</p>
<?php else: ?>
<?php foreach ($legacyByClass as $group): ?>
<div class="mb-4">
<h6 class="mb-2"><?= esc($group['class_section_name'] ?? 'Class') ?></h6>
<div class="list-group">
<?php foreach ($group['items'] ?? [] as $item): ?>
<?php $lst = strtolower((string) ($item['status'] ?? '')); ?>
<?php
$lst = strtolower((string) ($item['status'] ?? ''));
$archivePdfFile = trim((string) ($item['final_pdf_file'] ?? ''));
if ($archivePdfFile === '' && strtolower(pathinfo((string) ($item['final_file'] ?? ''), PATHINFO_EXTENSION)) === 'pdf') {
$archivePdfFile = (string) $item['final_file'];
}
?>
<div class="list-group-item d-flex justify-content-between align-items-start flex-wrap gap-2">
<div class="me-3">
<div class="fw-semibold"><?= esc($item['draft_title'] ?? 'Legacy exam') ?></div>
@@ -337,14 +346,28 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<?= esc($item['exam_type'] ?? 'N/A') ?> •
<?= esc($item['semester'] ?? '') ?> <?= esc($item['school_year'] ?? '') ?>
</div>
<?php if (!empty($item['class_section_names'])): ?>
<div class="small text-muted">Sections: <?= esc(implode(', ', $item['class_section_names'])) ?></div>
<?php endif; ?>
<div class="small mt-1"><?= $renderBadge($lst, $statusBadges) ?></div>
</div>
<div class="text-end d-flex flex-wrap gap-2 justify-content-end">
<?php if (!empty($item['final_file'])): ?>
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('exam-drafts/files/final/' . $item['final_file']) ?>" target="_blank" rel="noopener">View</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('exam-drafts/files/final/' . $item['final_file']) ?>" target="_blank" rel="noopener" download>Download</a>
<?php if ($archivePdfFile !== ''): ?>
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('exam-drafts/files/final/' . $archivePdfFile) ?>" target="_blank" rel="noopener">View PDF</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('exam-drafts/files/final/' . $archivePdfFile) ?>" target="_blank" rel="noopener" download>Download PDF</a>
<?php else: ?>
<span class="text-muted small">File missing</span>
<div class="text-start">
<div class="text-danger small mb-2">A formatting-safe PDF is required.</div>
<?= form_open_multipart($reviewActionUrl, ['class' => 'd-flex flex-wrap gap-2 align-items-center']) ?>
<?= csrf_field() ?>
<input type="hidden" name="draft_id" value="<?= (int) ($item['id'] ?? 0) ?>">
<input type="hidden" name="review_status" value="accepted">
<input type="hidden" name="acceptance_type" value="<?= esc($item['acceptance_type'] ?? 'as_is') ?>">
<input type="file" name="final_file" class="form-control form-control-sm" accept=".pdf,application/pdf" required>
<button type="submit" class="btn btn-sm btn-primary">Upload replacement PDF</button>
<?= form_close() ?>
<div class="form-text">Upload a PDF exported from Word if automatic conversion is unavailable.</div>
</div>
<?php endif; ?>
</div>
</div>