Files
coursehub/includes/functions.php
T
Harwinder Sekhon c6a970880c 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.
2026-07-11 11:24:37 -04:00

350 lines
13 KiB
PHP

<?php
require_once __DIR__ . '/db.php';
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`,
* 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
{
$root = rtrim(VIDEOS_ROOT, '/');
if (!is_dir($root)) {
throw new RuntimeException("VIDEOS_ROOT does not exist: $root");
}
$db = db();
$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)
VALUES (?, ?, ?, ?, ?, 0)
ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id), name = VALUES(name),
depth = VALUES(depth), sort_order = VALUES(sort_order), is_missing = 0,
id = LAST_INSERT_ID(id)'
);
$upsertVideo = $db->prepare(
'INSERT INTO videos (folder_id, name, filename, path, size_bytes, sort_order, is_missing)
VALUES (?, ?, ?, ?, ?, ?, 0)
ON DUPLICATE KEY UPDATE folder_id = VALUES(folder_id), name = VALUES(name),
filename = VALUES(filename), size_bytes = VALUES(size_bytes),
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, $upsertQuiz,
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void {
$entries = scandir($dir) ?: [];
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
usort($entries, 'strnatcasecmp'); // "Video2" before "Video10"
$folderSort = 0;
$videoSort = 0;
$quizSort = 0;
foreach ($entries as $entry) {
$full = $dir . '/' . $entry;
$rel = $relBase === '' ? $entry : $relBase . '/' . $entry;
if (is_dir($full)) {
$upsertFolder->execute([$parentId, $entry, $rel, $depth, $folderSort++]);
$folderId = (int)$db->lastInsertId();
$seenFolderIds[] = $folderId;
$stats['folders']++;
$walk($full, $folderId, $rel, $depth + 1);
} elseif (is_file($full) && strtolower(pathinfo($entry, PATHINFO_EXTENSION)) === 'mp4') {
if ($parentId === null) {
continue; // mp4 directly in the root is skipped; use course folders
}
$name = pathinfo($entry, PATHINFO_FILENAME);
$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']++;
}
}
};
$db->beginTransaction();
try {
$walk($root, null, '', 0);
// Anything not seen this pass no longer exists on disk.
$flagMissing = function (string $table, array $seenIds) use ($db): int {
if ($seenIds) {
$in = implode(',', array_map('intval', $seenIds));
$sql = "UPDATE $table SET is_missing = 1 WHERE is_missing = 0 AND id NOT IN ($in)";
} else {
$sql = "UPDATE $table SET is_missing = 1 WHERE is_missing = 0";
}
return $db->exec($sql);
};
$stats['missing_folders'] = $flagMissing('folders', $seenFolderIds);
$stats['missing_videos'] = $flagMissing('videos', $seenVideoIds);
$stats['missing_quizzes'] = $flagMissing('quizzes', $seenQuizIds);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
return $stats;
}
/* ---------------------------------------------------------------------------
* Progress helpers
* ------------------------------------------------------------------------- */
/**
* 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,
COALESCE(SUM(
CASE
WHEN vp.completed = 1 THEN 1
WHEN vp.duration_seconds > 0
THEN LEAST(vp.position_seconds / vp.duration_seconds, 1)
ELSE 0
END
), 0) AS credit
FROM videos v
JOIN folders f ON f.id = v.folder_id
LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ?
WHERE v.is_missing = 0 AND f.is_missing = 0
AND (f.path = ? OR f.path LIKE ?)"
);
$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)$videos['completed'] + (int)$quizzes['completed'],
'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0,
];
}
/** Escape LIKE wildcards in a path used as a prefix. */
function likePrefix(string $path): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $path);
}
function getFolder(int $id): ?array
{
$stmt = db()->prepare('SELECT * FROM folders WHERE id = ? AND is_missing = 0');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
function getVideo(int $id): ?array
{
$stmt = db()->prepare(
'SELECT v.*, f.path AS folder_path, f.name AS folder_name
FROM videos v JOIN folders f ON f.id = v.folder_id
WHERE v.id = ? AND v.is_missing = 0'
);
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
function childFolders(?int $parentId): array
{
$sql = 'SELECT * FROM folders WHERE is_missing = 0 AND parent_id '
. ($parentId === null ? 'IS NULL' : '= ?')
. ' ORDER BY sort_order, name';
$stmt = db()->prepare($sql);
$stmt->execute($parentId === null ? [] : [$parentId]);
return $stmt->fetchAll();
}
/** Videos in a folder, joined with the user's progress. */
function folderVideos(int $folderId, int $userId): array
{
$stmt = db()->prepare(
'SELECT v.*, vp.position_seconds, vp.duration_seconds AS watched_duration, vp.completed
FROM videos v
LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ?
WHERE v.folder_id = ? AND v.is_missing = 0
ORDER BY v.sort_order, v.name'
);
$stmt->execute([$userId, $folderId]);
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
{
$crumbs = [$folder];
while ($folder['parent_id'] !== null) {
$folder = getFolder((int)$folder['parent_id']);
if (!$folder) break;
array_unshift($crumbs, $folder);
}
return $crumbs;
}
/** Most recently watched, unfinished videos for the "Continue watching" strip. */
function continueWatching(int $userId, int $limit = 6): array
{
$stmt = db()->prepare(
'SELECT v.id, v.name, f.path AS folder_path,
vp.position_seconds, vp.duration_seconds, vp.updated_at
FROM video_progress vp
JOIN videos v ON v.id = vp.video_id AND v.is_missing = 0
JOIN folders f ON f.id = v.folder_id
WHERE vp.user_id = ? AND vp.completed = 0 AND vp.position_seconds > 5
ORDER BY vp.updated_at DESC
LIMIT ' . (int)$limit
);
$stmt->execute([$userId]);
return $stmt->fetchAll();
}
function formatDuration(?float $seconds): string
{
if ($seconds === null || $seconds <= 0) return '';
$s = (int)round($seconds);
return $s >= 3600
? sprintf('%d:%02d:%02d', intdiv($s, 3600), intdiv($s % 3600, 60), $s % 60)
: sprintf('%d:%02d', intdiv($s, 60), $s % 60);
}