Add quiz support: parse *_quiz_solved.txt files, take and grade quizzes
Scanner indexes quiz files found next to videos ([&] questions, "- " options, "-- Correct" markers; _notsolved twins ignored). Quizzes appear in the folder content list interleaved with videos by filename order. New api/quiz.php serves questions without answers and grades attempts server-side; per-user best/last score and attempts stored in quiz_progress. Folder/course progress now aggregates videos + quizzes with partial credit for partial scores. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+141
-13
@@ -6,11 +6,53 @@ function h(?string $s): string
|
||||
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Quiz file parser
|
||||
* Format ("*_quiz_solved.txt"):
|
||||
* [&] Question text (Select two)
|
||||
* - wrong option
|
||||
* - right option -- Correct
|
||||
* Returns [['text', 'options' => [...], 'correct' => [optIndex,...], 'multi'], ...]
|
||||
* ------------------------------------------------------------------------- */
|
||||
function parseQuizFile(string $absPath): array
|
||||
{
|
||||
$raw = @file_get_contents($absPath);
|
||||
if ($raw === false) return [];
|
||||
|
||||
$questions = [];
|
||||
$cur = null;
|
||||
foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') continue;
|
||||
|
||||
if (str_starts_with($line, '[&]')) {
|
||||
if ($cur && $cur['options']) $questions[] = $cur;
|
||||
$cur = ['text' => trim(substr($line, 3)), 'options' => [], 'correct' => []];
|
||||
} elseif (str_starts_with($line, '-') && $cur !== null) {
|
||||
$opt = trim(substr($line, 1));
|
||||
if (preg_match('/\s*--\s*correct\s*$/i', $opt)) {
|
||||
$opt = trim(preg_replace('/\s*--\s*correct\s*$/i', '', $opt));
|
||||
$cur['correct'][] = count($cur['options']);
|
||||
}
|
||||
$cur['options'][] = $opt;
|
||||
} elseif ($cur !== null && !$cur['options']) {
|
||||
$cur['text'] .= ' ' . $line; // wrapped question text
|
||||
}
|
||||
}
|
||||
if ($cur && $cur['options']) $questions[] = $cur;
|
||||
|
||||
foreach ($questions as &$q) {
|
||||
$q['multi'] = count($q['correct']) > 1;
|
||||
}
|
||||
return $questions;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Library scanner
|
||||
* Walks VIDEOS_ROOT recursively, upserts every directory into `folders` and
|
||||
* every *.mp4 into `videos` (keyed by relative path so IDs stay stable across
|
||||
* rescans), then flags rows whose files disappeared as is_missing.
|
||||
* Walks VIDEOS_ROOT recursively, upserts every directory into `folders`,
|
||||
* every *.mp4 into `videos`, and every *_quiz_solved.txt into `quizzes`
|
||||
* (keyed by relative path so IDs stay stable across rescans), then flags
|
||||
* rows whose files disappeared as is_missing.
|
||||
* ------------------------------------------------------------------------- */
|
||||
function scanLibrary(): array
|
||||
{
|
||||
@@ -20,9 +62,11 @@ function scanLibrary(): array
|
||||
}
|
||||
|
||||
$db = db();
|
||||
$stats = ['folders' => 0, 'videos' => 0, 'missing_folders' => 0, 'missing_videos' => 0];
|
||||
$stats = ['folders' => 0, 'videos' => 0, 'quizzes' => 0,
|
||||
'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0];
|
||||
$seenFolderIds = [];
|
||||
$seenVideoIds = [];
|
||||
$seenQuizIds = [];
|
||||
|
||||
$upsertFolder = $db->prepare(
|
||||
'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing)
|
||||
@@ -39,9 +83,18 @@ function scanLibrary(): array
|
||||
sort_order = VALUES(sort_order), is_missing = 0,
|
||||
id = LAST_INSERT_ID(id)'
|
||||
);
|
||||
$upsertQuiz = $db->prepare(
|
||||
'INSERT INTO quizzes (folder_id, name, filename, path, num_questions, sort_order, is_missing)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||||
ON DUPLICATE KEY UPDATE folder_id = VALUES(folder_id), name = VALUES(name),
|
||||
filename = VALUES(filename), num_questions = VALUES(num_questions),
|
||||
sort_order = VALUES(sort_order), is_missing = 0,
|
||||
id = LAST_INSERT_ID(id)'
|
||||
);
|
||||
|
||||
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth)
|
||||
use (&$walk, $db, $upsertFolder, $upsertVideo, &$stats, &$seenFolderIds, &$seenVideoIds): void {
|
||||
use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz,
|
||||
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void {
|
||||
|
||||
$entries = scandir($dir) ?: [];
|
||||
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
|
||||
@@ -49,6 +102,7 @@ function scanLibrary(): array
|
||||
|
||||
$folderSort = 0;
|
||||
$videoSort = 0;
|
||||
$quizSort = 0;
|
||||
foreach ($entries as $entry) {
|
||||
$full = $dir . '/' . $entry;
|
||||
$rel = $relBase === '' ? $entry : $relBase . '/' . $entry;
|
||||
@@ -67,6 +121,18 @@ function scanLibrary(): array
|
||||
$upsertVideo->execute([$parentId, $name, $entry, $rel, filesize($full) ?: 0, $videoSort++]);
|
||||
$seenVideoIds[] = (int)$db->lastInsertId();
|
||||
$stats['videos']++;
|
||||
} elseif (is_file($full) && preg_match('/_quiz_solved\.txt$/i', $entry)) {
|
||||
if ($parentId === null) {
|
||||
continue;
|
||||
}
|
||||
$numQuestions = count(parseQuizFile($full));
|
||||
if ($numQuestions === 0) {
|
||||
continue; // unparseable file — don't index an empty quiz
|
||||
}
|
||||
$name = trim(preg_replace('/_quiz_solved\.txt$/i', '', $entry));
|
||||
$upsertQuiz->execute([$parentId, $name, $entry, $rel, $numQuestions, $quizSort++]);
|
||||
$seenQuizIds[] = (int)$db->lastInsertId();
|
||||
$stats['quizzes']++;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -87,6 +153,7 @@ function scanLibrary(): array
|
||||
};
|
||||
$stats['missing_folders'] = $flagMissing('folders', $seenFolderIds);
|
||||
$stats['missing_videos'] = $flagMissing('videos', $seenVideoIds);
|
||||
$stats['missing_quizzes'] = $flagMissing('quizzes', $seenQuizIds);
|
||||
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
@@ -101,12 +168,14 @@ function scanLibrary(): array
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Progress for one folder's whole subtree, matched by path prefix.
|
||||
* Returns ['total' => n, 'completed' => n, 'percent' => 0-100].
|
||||
* Percent gives partial credit for videos that are started but not finished.
|
||||
* Progress for one folder's whole subtree (videos + quizzes), matched by
|
||||
* path prefix. Returns ['total' => n, 'completed' => n, 'percent' => 0-100].
|
||||
* Percent gives partial credit for half-watched videos and partial scores.
|
||||
*/
|
||||
function folderProgress(int $userId, string $folderPath): array
|
||||
{
|
||||
$args = [$userId, $folderPath, likePrefix($folderPath) . '/%'];
|
||||
|
||||
$stmt = db()->prepare(
|
||||
"SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(vp.completed), 0) AS completed,
|
||||
@@ -124,13 +193,35 @@ function folderProgress(int $userId, string $folderPath): array
|
||||
WHERE v.is_missing = 0 AND f.is_missing = 0
|
||||
AND (f.path = ? OR f.path LIKE ?)"
|
||||
);
|
||||
$stmt->execute([$userId, $folderPath, likePrefix($folderPath) . '/%']);
|
||||
$row = $stmt->fetch();
|
||||
$total = (int)$row['total'];
|
||||
$stmt->execute($args);
|
||||
$videos = $stmt->fetch();
|
||||
|
||||
$stmt = db()->prepare(
|
||||
"SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(qp.completed), 0) AS completed,
|
||||
COALESCE(SUM(
|
||||
CASE
|
||||
WHEN qp.completed = 1 THEN 1
|
||||
WHEN q.num_questions > 0
|
||||
THEN LEAST(COALESCE(qp.best_score, 0) / q.num_questions, 1)
|
||||
ELSE 0
|
||||
END
|
||||
), 0) AS credit
|
||||
FROM quizzes q
|
||||
JOIN folders f ON f.id = q.folder_id
|
||||
LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ?
|
||||
WHERE q.is_missing = 0 AND f.is_missing = 0
|
||||
AND (f.path = ? OR f.path LIKE ?)"
|
||||
);
|
||||
$stmt->execute($args);
|
||||
$quizzes = $stmt->fetch();
|
||||
|
||||
$total = (int)$videos['total'] + (int)$quizzes['total'];
|
||||
$credit = $videos['credit'] + $quizzes['credit'];
|
||||
return [
|
||||
'total' => $total,
|
||||
'completed' => (int)$row['completed'],
|
||||
'percent' => $total > 0 ? (int)round(100 * $row['credit'] / $total) : 0,
|
||||
'completed' => (int)$videos['completed'] + (int)$quizzes['completed'],
|
||||
'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -182,6 +273,43 @@ function folderVideos(int $folderId, int $userId): array
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function getQuiz(int $id): ?array
|
||||
{
|
||||
$stmt = db()->prepare(
|
||||
'SELECT q.*, f.path AS folder_path, f.name AS folder_name
|
||||
FROM quizzes q JOIN folders f ON f.id = q.folder_id
|
||||
WHERE q.id = ? AND q.is_missing = 0'
|
||||
);
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
/** Quizzes in a folder, joined with the user's results. */
|
||||
function folderQuizzes(int $folderId, int $userId): array
|
||||
{
|
||||
$stmt = db()->prepare(
|
||||
'SELECT q.*, qp.best_score, qp.last_score, qp.attempts, qp.completed
|
||||
FROM quizzes q
|
||||
LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ?
|
||||
WHERE q.folder_id = ? AND q.is_missing = 0
|
||||
ORDER BY q.sort_order, q.name'
|
||||
);
|
||||
$stmt->execute([$userId, $folderId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/** Absolute, containment-checked path of a quiz file (null if gone). */
|
||||
function quizFilePath(array $quiz): ?string
|
||||
{
|
||||
$file = realpath(rtrim(VIDEOS_ROOT, '/') . '/' . $quiz['path']);
|
||||
$root = realpath(VIDEOS_ROOT);
|
||||
if ($file === false || $root === false
|
||||
|| !str_starts_with($file, $root . DIRECTORY_SEPARATOR) || !is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
return $file;
|
||||
}
|
||||
|
||||
/** Breadcrumb chain root -> ... -> folder. */
|
||||
function folderBreadcrumbs(array $folder): array
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user