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:
@@ -72,6 +72,30 @@ Top-level folders become courses on the home page.
|
|||||||
|
|
||||||
5. Open the site, create an account, and watch.
|
5. Open the site, create an account, and watch.
|
||||||
|
|
||||||
|
## Quizzes
|
||||||
|
|
||||||
|
Course folders may contain quiz text files next to the videos. Files named
|
||||||
|
`*_quiz_solved.txt` are indexed (a `*_quiz_notsolved.txt` twin is ignored —
|
||||||
|
it's the same content without answers). Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
[&] Which two are supported CLI connectivity methods? (Select two)
|
||||||
|
- RDP
|
||||||
|
- rlogin
|
||||||
|
- GUI widget -- Correct
|
||||||
|
- FortiExplorer -- Correct
|
||||||
|
```
|
||||||
|
|
||||||
|
Questions start with `[&]`, options with `- `, and correct options end with
|
||||||
|
`-- Correct`. Questions with more than one correct option automatically render
|
||||||
|
as checkboxes instead of radios.
|
||||||
|
|
||||||
|
Quizzes are graded server-side (answers are never sent to the browser before
|
||||||
|
submission). A question counts only if the selected set exactly matches the
|
||||||
|
correct set. Best score, last score, and attempt count are stored per user;
|
||||||
|
a quiz is *completed* once every question is answered correctly in one
|
||||||
|
attempt, and quiz scores count toward folder progress alongside videos.
|
||||||
|
|
||||||
## How progress works
|
## How progress works
|
||||||
|
|
||||||
- The player POSTs `{video_id, position, duration}` to `api/progress.php`
|
- The player POSTs `{video_id, position, duration}` to `api/progress.php`
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function videoSummary(array $v): array
|
|||||||
return [
|
return [
|
||||||
'id' => (int)$v['id'],
|
'id' => (int)$v['id'],
|
||||||
'name' => $v['name'],
|
'name' => $v['name'],
|
||||||
|
'filename' => $v['filename'],
|
||||||
'duration' => $duration !== null ? (float)$duration : null,
|
'duration' => $duration !== null ? (float)$duration : null,
|
||||||
'position' => $position,
|
'position' => $position,
|
||||||
'completed' => (bool)($v['completed'] ?? false),
|
'completed' => (bool)($v['completed'] ?? false),
|
||||||
@@ -31,6 +32,19 @@ function videoSummary(array $v): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quizSummary(array $q): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int)$q['id'],
|
||||||
|
'name' => $q['name'],
|
||||||
|
'filename' => $q['filename'],
|
||||||
|
'questions' => (int)$q['num_questions'],
|
||||||
|
'attempts' => (int)($q['attempts'] ?? 0),
|
||||||
|
'best_score' => (int)($q['best_score'] ?? 0),
|
||||||
|
'completed' => (bool)($q['completed'] ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function crumbList(array $folder): array
|
function crumbList(array $folder): array
|
||||||
{
|
{
|
||||||
return array_map(
|
return array_map(
|
||||||
@@ -70,6 +84,8 @@ switch ($view) {
|
|||||||
childFolders((int)$folder['id'])),
|
childFolders((int)$folder['id'])),
|
||||||
'videos' => array_map('videoSummary',
|
'videos' => array_map('videoSummary',
|
||||||
folderVideos((int)$folder['id'], $userId)),
|
folderVideos((int)$folder['id'], $userId)),
|
||||||
|
'quizzes' => array_map('quizSummary',
|
||||||
|
folderQuizzes((int)$folder['id'], $userId)),
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
// GET api/quiz.php?id=N -> quiz questions + options (no answers) + user's results
|
||||||
|
// POST api/quiz.php -> grade an attempt, save the result
|
||||||
|
// JSON body: {quiz_id, answers: [[optIdx,...], ...]} (one array per question)
|
||||||
|
// Response includes the correct options so the UI can mark the attempt.
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/functions.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$userId = requireLoginApi();
|
||||||
|
|
||||||
|
function quizProgressRow(int $userId, int $quizId): array
|
||||||
|
{
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'SELECT best_score, last_score, total_questions, attempts, completed
|
||||||
|
FROM quiz_progress WHERE user_id = ? AND quiz_id = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$userId, $quizId]);
|
||||||
|
return $stmt->fetch()
|
||||||
|
?: ['best_score' => 0, 'last_score' => 0, 'total_questions' => 0,
|
||||||
|
'attempts' => 0, 'completed' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
|
$quiz = getQuiz((int)($_GET['id'] ?? 0));
|
||||||
|
if (!$quiz) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Quiz not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$file = quizFilePath($quiz);
|
||||||
|
$questions = $file ? parseQuizFile($file) : [];
|
||||||
|
if (!$questions) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Quiz file is missing — rescan the library']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$folder = getFolder((int)$quiz['folder_id']);
|
||||||
|
$p = quizProgressRow($userId, (int)$quiz['id']);
|
||||||
|
echo json_encode([
|
||||||
|
'quiz' => ['id' => (int)$quiz['id'], 'name' => $quiz['name']],
|
||||||
|
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
|
||||||
|
'breadcrumbs' => array_map(
|
||||||
|
fn($c) => ['id' => (int)$c['id'], 'name' => $c['name']],
|
||||||
|
folderBreadcrumbs($folder)
|
||||||
|
),
|
||||||
|
'questions' => array_map(fn($q) => [
|
||||||
|
'text' => $q['text'],
|
||||||
|
'multi' => $q['multi'],
|
||||||
|
'options' => $q['options'],
|
||||||
|
], $questions),
|
||||||
|
'progress' => [
|
||||||
|
'attempts' => (int)$p['attempts'],
|
||||||
|
'best_score' => (int)$p['best_score'],
|
||||||
|
'last_score' => (int)$p['last_score'],
|
||||||
|
'total' => count($questions),
|
||||||
|
'completed' => (bool)$p['completed'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$quiz = getQuiz((int)($data['quiz_id'] ?? 0));
|
||||||
|
$answers = $data['answers'] ?? null;
|
||||||
|
if (!$quiz || !is_array($answers)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Bad request']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = quizFilePath($quiz);
|
||||||
|
$questions = $file ? parseQuizFile($file) : [];
|
||||||
|
if (!$questions) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Quiz file is missing — rescan the library']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$score = 0;
|
||||||
|
$results = [];
|
||||||
|
foreach ($questions as $i => $q) {
|
||||||
|
$selected = array_values(array_unique(array_map('intval', (array)($answers[$i] ?? []))));
|
||||||
|
sort($selected);
|
||||||
|
$correct = $q['correct'];
|
||||||
|
sort($correct);
|
||||||
|
$isCorrect = $selected === $correct;
|
||||||
|
if ($isCorrect) $score++;
|
||||||
|
$results[] = [
|
||||||
|
'correct' => $isCorrect,
|
||||||
|
'correct_options' => $correct,
|
||||||
|
'selected' => $selected,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = count($questions);
|
||||||
|
$completed = ($score === $total) ? 1 : 0;
|
||||||
|
|
||||||
|
db()->prepare(
|
||||||
|
'INSERT INTO quiz_progress
|
||||||
|
(user_id, quiz_id, best_score, last_score, total_questions, attempts, completed)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_score = VALUES(last_score),
|
||||||
|
best_score = GREATEST(best_score, VALUES(best_score)),
|
||||||
|
total_questions = VALUES(total_questions),
|
||||||
|
attempts = attempts + 1,
|
||||||
|
completed = GREATEST(completed, VALUES(completed))'
|
||||||
|
)->execute([$userId, (int)$quiz['id'], $score, $score, $total, $completed]);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'score' => $score,
|
||||||
|
'total' => $total,
|
||||||
|
'completed' => (bool)$completed,
|
||||||
|
'results' => $results,
|
||||||
|
]);
|
||||||
+126
-13
@@ -173,7 +173,7 @@
|
|||||||
</div>
|
</div>
|
||||||
${progressBar(c.progress.percent)}
|
${progressBar(c.progress.percent)}
|
||||||
<div class="small text-secondary mt-1">
|
<div class="small text-secondary mt-1">
|
||||||
${c.progress.completed} / ${c.progress.total} videos completed · ${c.progress.percent}%
|
${c.progress.completed} / ${c.progress.total} completed · ${c.progress.percent}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -206,23 +206,44 @@
|
|||||||
</a>`).join('')}
|
</a>`).join('')}
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
|
|
||||||
const videos = data.videos.length ? `
|
const items = [
|
||||||
<h2 class="h6 text-secondary text-uppercase">Videos</h2>
|
...data.videos.map((v) => ({ ...v, kind: 'video' })),
|
||||||
|
...data.quizzes.map((q) => ({ ...q, kind: 'quiz' })),
|
||||||
|
].sort((a, b) => a.filename.localeCompare(b.filename, undefined,
|
||||||
|
{ numeric: true, sensitivity: 'base' }));
|
||||||
|
|
||||||
|
const content = items.length ? `
|
||||||
|
<h2 class="h6 text-secondary text-uppercase">Content</h2>
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
${data.videos.map((v) => {
|
${items.map((it) => {
|
||||||
const icon = v.completed
|
if (it.kind === 'quiz') {
|
||||||
|
const icon = it.completed
|
||||||
|
? '<i class="bi bi-check-circle-fill text-success fs-4"></i>'
|
||||||
|
: '<i class="bi bi-patch-question-fill text-info fs-4"></i>';
|
||||||
|
return `
|
||||||
|
<a href="#/quiz/${it.id}" class="list-group-item list-group-item-action d-flex align-items-center gap-3">
|
||||||
|
${icon}
|
||||||
|
<div class="flex-grow-1 min-w-0">
|
||||||
|
<div class="text-truncate">${esc(it.name)}</div>
|
||||||
|
</div>
|
||||||
|
${it.attempts ? `<span class="badge ${it.completed ? 'text-bg-success' : 'text-bg-secondary'}">
|
||||||
|
${it.best_score}/${it.questions}</span>` : ''}
|
||||||
|
<span class="text-secondary small">Quiz · ${it.questions} questions</span>
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
const icon = it.completed
|
||||||
? '<i class="bi bi-check-circle-fill text-success fs-4"></i>'
|
? '<i class="bi bi-check-circle-fill text-success fs-4"></i>'
|
||||||
: (v.position > 5
|
: (it.position > 5
|
||||||
? '<i class="bi bi-play-circle-fill text-primary fs-4"></i>'
|
? '<i class="bi bi-play-circle-fill text-primary fs-4"></i>'
|
||||||
: '<i class="bi bi-circle text-secondary fs-4"></i>');
|
: '<i class="bi bi-circle text-secondary fs-4"></i>');
|
||||||
return `
|
return `
|
||||||
<a href="#/watch/${v.id}" class="list-group-item list-group-item-action d-flex align-items-center gap-3">
|
<a href="#/watch/${it.id}" class="list-group-item list-group-item-action d-flex align-items-center gap-3">
|
||||||
${icon}
|
${icon}
|
||||||
<div class="flex-grow-1 min-w-0">
|
<div class="flex-grow-1 min-w-0">
|
||||||
<div class="text-truncate">${esc(v.name)}</div>
|
<div class="text-truncate">${esc(it.name)}</div>
|
||||||
${!v.completed && v.percent > 0 ? `<div class="mt-1">${progressBar(v.percent, '')}</div>` : ''}
|
${!it.completed && it.percent > 0 ? `<div class="mt-1">${progressBar(it.percent, '')}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-secondary small">${fmt(v.duration)}</span>
|
<span class="text-secondary small">${fmt(it.duration)}</span>
|
||||||
</a>`;
|
</a>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</div>` : (!data.subfolders.length ? '<div class="alert alert-secondary">This folder is empty.</div>' : '');
|
</div>` : (!data.subfolders.length ? '<div class="alert alert-secondary">This folder is empty.</div>' : '');
|
||||||
@@ -231,10 +252,100 @@
|
|||||||
${breadcrumbs(data.breadcrumbs)}
|
${breadcrumbs(data.breadcrumbs)}
|
||||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
<h1 class="h4 mb-0"><i class="bi bi-folder2-open text-warning me-2"></i>${esc(data.folder.name)}</h1>
|
<h1 class="h4 mb-0"><i class="bi bi-folder2-open text-warning me-2"></i>${esc(data.folder.name)}</h1>
|
||||||
<div class="text-secondary small">${data.progress.completed} / ${data.progress.total} videos completed</div>
|
<div class="text-secondary small">${data.progress.completed} / ${data.progress.total} completed</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">${progressBar(data.progress.percent)}</div>
|
<div class="mb-4">${progressBar(data.progress.percent)}</div>
|
||||||
${subfolders}${videos}`;
|
${subfolders}${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderQuiz(id) {
|
||||||
|
spinner();
|
||||||
|
const data = await api(`api/quiz.php?id=${id}`);
|
||||||
|
|
||||||
|
const qHtml = data.questions.map((q, qi) => `
|
||||||
|
<div class="card mb-3" data-qi="${qi}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="fw-semibold mb-2">
|
||||||
|
${qi + 1}. ${esc(q.text)}
|
||||||
|
${q.multi ? '<span class="badge text-bg-info ms-1">multiple answers</span>' : ''}
|
||||||
|
</div>
|
||||||
|
${q.options.map((o, oi) => `
|
||||||
|
<div class="form-check" data-oi="${oi}">
|
||||||
|
<input class="form-check-input" type="${q.multi ? 'checkbox' : 'radio'}"
|
||||||
|
name="q${qi}" id="q${qi}o${oi}" value="${oi}">
|
||||||
|
<label class="form-check-label" for="q${qi}o${oi}">${esc(o)}</label>
|
||||||
|
<span class="ms-1 quiz-mark"></span>
|
||||||
|
</div>`).join('')}
|
||||||
|
<div class="small mt-2 quiz-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
const best = data.progress.attempts
|
||||||
|
? `Best score: ${data.progress.best_score}/${data.progress.total}` +
|
||||||
|
(data.progress.completed ? ' <i class="bi bi-check-circle-fill text-success"></i>' : '')
|
||||||
|
: 'Not attempted yet';
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
${breadcrumbs(data.breadcrumbs, data.quiz.name)}
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<h1 class="h4 mb-0"><i class="bi bi-patch-question-fill text-info me-2"></i>${esc(data.quiz.name)}</h1>
|
||||||
|
<div class="text-secondary small">${best}</div>
|
||||||
|
</div>
|
||||||
|
<form id="quiz-form">
|
||||||
|
${qHtml}
|
||||||
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
|
<button class="btn btn-primary" type="submit" id="quiz-submit">
|
||||||
|
<i class="bi bi-send me-1"></i>Submit answers
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-outline-secondary" href="#/folder/${data.folder.id}">
|
||||||
|
Back to ${esc(data.folder.name)}
|
||||||
|
</a>
|
||||||
|
<span id="quiz-score" class="fw-semibold"></span>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
|
||||||
|
const form = document.getElementById('quiz-form');
|
||||||
|
let graded = false;
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (graded) { renderQuiz(id); return; } // "Try again" re-renders fresh
|
||||||
|
|
||||||
|
const answers = data.questions.map((q, qi) =>
|
||||||
|
[...form.querySelectorAll(`input[name="q${qi}"]:checked`)].map((i) => +i.value));
|
||||||
|
|
||||||
|
const res = await api('api/quiz.php', { json: { quiz_id: id, answers } });
|
||||||
|
graded = true;
|
||||||
|
|
||||||
|
res.results.forEach((r, qi) => {
|
||||||
|
const card = form.querySelector(`[data-qi="${qi}"]`);
|
||||||
|
r.correct_options.forEach((oi) => {
|
||||||
|
const row = card.querySelector(`[data-oi="${oi}"]`);
|
||||||
|
row.querySelector('label').classList.add('text-success', 'fw-semibold');
|
||||||
|
row.querySelector('.quiz-mark').innerHTML = '<i class="bi bi-check-lg text-success"></i>';
|
||||||
|
});
|
||||||
|
r.selected.filter((oi) => !r.correct_options.includes(oi)).forEach((oi) => {
|
||||||
|
const row = card.querySelector(`[data-oi="${oi}"]`);
|
||||||
|
row.querySelector('label').classList.add('text-danger');
|
||||||
|
row.querySelector('.quiz-mark').innerHTML = '<i class="bi bi-x-lg text-danger"></i>';
|
||||||
|
});
|
||||||
|
const fb = card.querySelector('.quiz-feedback');
|
||||||
|
fb.innerHTML = r.correct
|
||||||
|
? '<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Correct</span>'
|
||||||
|
: '<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>Incorrect</span>';
|
||||||
|
card.classList.add('border', r.correct ? 'border-success' : 'border-danger');
|
||||||
|
});
|
||||||
|
|
||||||
|
form.querySelectorAll('input').forEach((i) => { i.disabled = true; });
|
||||||
|
document.getElementById('quiz-score').textContent = `Score: ${res.score}/${res.total}`;
|
||||||
|
const btn = document.getElementById('quiz-submit');
|
||||||
|
btn.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Try again';
|
||||||
|
btn.classList.replace('btn-primary', 'btn-outline-primary');
|
||||||
|
toast(res.completed
|
||||||
|
? `Perfect score — ${res.score}/${res.total}!`
|
||||||
|
: `Scored ${res.score}/${res.total}.`,
|
||||||
|
res.completed ? 'success' : 'secondary');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderWatch(id) {
|
async function renderWatch(id) {
|
||||||
@@ -374,6 +485,7 @@
|
|||||||
try {
|
try {
|
||||||
if ((m = hash.match(/^#\/folder\/(\d+)$/))) await renderFolder(+m[1]);
|
if ((m = hash.match(/^#\/folder\/(\d+)$/))) await renderFolder(+m[1]);
|
||||||
else if ((m = hash.match(/^#\/watch\/(\d+)$/))) await renderWatch(+m[1]);
|
else if ((m = hash.match(/^#\/watch\/(\d+)$/))) await renderWatch(+m[1]);
|
||||||
|
else if ((m = hash.match(/^#\/quiz\/(\d+)$/))) await renderQuiz(+m[1]);
|
||||||
else await renderHome();
|
else await renderHome();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (state.user) {
|
if (state.user) {
|
||||||
@@ -399,7 +511,8 @@
|
|||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Scanning…';
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Scanning…';
|
||||||
try {
|
try {
|
||||||
const res = await api('api/scan.php', { method: 'POST' });
|
const res = await api('api/scan.php', { method: 'POST' });
|
||||||
toast(`Scan complete: ${res.stats.folders} folders, ${res.stats.videos} videos indexed.`);
|
toast(`Scan complete: ${res.stats.folders} folders, ${res.stats.videos} videos, `
|
||||||
|
+ `${res.stats.quizzes} quizzes indexed.`);
|
||||||
route(); // refresh current view with new data
|
route(); // refresh current view with new data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, 'danger');
|
toast(err.message, 'danger');
|
||||||
|
|||||||
+141
-13
@@ -6,11 +6,53 @@ function h(?string $s): string
|
|||||||
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
|
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
|
* Library scanner
|
||||||
* Walks VIDEOS_ROOT recursively, upserts every directory into `folders` and
|
* Walks VIDEOS_ROOT recursively, upserts every directory into `folders`,
|
||||||
* every *.mp4 into `videos` (keyed by relative path so IDs stay stable across
|
* every *.mp4 into `videos`, and every *_quiz_solved.txt into `quizzes`
|
||||||
* rescans), then flags rows whose files disappeared as is_missing.
|
* (keyed by relative path so IDs stay stable across rescans), then flags
|
||||||
|
* rows whose files disappeared as is_missing.
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
function scanLibrary(): array
|
function scanLibrary(): array
|
||||||
{
|
{
|
||||||
@@ -20,9 +62,11 @@ function scanLibrary(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$db = db();
|
$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 = [];
|
$seenFolderIds = [];
|
||||||
$seenVideoIds = [];
|
$seenVideoIds = [];
|
||||||
|
$seenQuizIds = [];
|
||||||
|
|
||||||
$upsertFolder = $db->prepare(
|
$upsertFolder = $db->prepare(
|
||||||
'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing)
|
'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,
|
sort_order = VALUES(sort_order), is_missing = 0,
|
||||||
id = LAST_INSERT_ID(id)'
|
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)
|
$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 = scandir($dir) ?: [];
|
||||||
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
|
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
|
||||||
@@ -49,6 +102,7 @@ function scanLibrary(): array
|
|||||||
|
|
||||||
$folderSort = 0;
|
$folderSort = 0;
|
||||||
$videoSort = 0;
|
$videoSort = 0;
|
||||||
|
$quizSort = 0;
|
||||||
foreach ($entries as $entry) {
|
foreach ($entries as $entry) {
|
||||||
$full = $dir . '/' . $entry;
|
$full = $dir . '/' . $entry;
|
||||||
$rel = $relBase === '' ? $entry : $relBase . '/' . $entry;
|
$rel = $relBase === '' ? $entry : $relBase . '/' . $entry;
|
||||||
@@ -67,6 +121,18 @@ function scanLibrary(): array
|
|||||||
$upsertVideo->execute([$parentId, $name, $entry, $rel, filesize($full) ?: 0, $videoSort++]);
|
$upsertVideo->execute([$parentId, $name, $entry, $rel, filesize($full) ?: 0, $videoSort++]);
|
||||||
$seenVideoIds[] = (int)$db->lastInsertId();
|
$seenVideoIds[] = (int)$db->lastInsertId();
|
||||||
$stats['videos']++;
|
$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_folders'] = $flagMissing('folders', $seenFolderIds);
|
||||||
$stats['missing_videos'] = $flagMissing('videos', $seenVideoIds);
|
$stats['missing_videos'] = $flagMissing('videos', $seenVideoIds);
|
||||||
|
$stats['missing_quizzes'] = $flagMissing('quizzes', $seenQuizIds);
|
||||||
|
|
||||||
$db->commit();
|
$db->commit();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -101,12 +168,14 @@ function scanLibrary(): array
|
|||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progress for one folder's whole subtree, matched by path prefix.
|
* Progress for one folder's whole subtree (videos + quizzes), matched by
|
||||||
* Returns ['total' => n, 'completed' => n, 'percent' => 0-100].
|
* path prefix. Returns ['total' => n, 'completed' => n, 'percent' => 0-100].
|
||||||
* Percent gives partial credit for videos that are started but not finished.
|
* Percent gives partial credit for half-watched videos and partial scores.
|
||||||
*/
|
*/
|
||||||
function folderProgress(int $userId, string $folderPath): array
|
function folderProgress(int $userId, string $folderPath): array
|
||||||
{
|
{
|
||||||
|
$args = [$userId, $folderPath, likePrefix($folderPath) . '/%'];
|
||||||
|
|
||||||
$stmt = db()->prepare(
|
$stmt = db()->prepare(
|
||||||
"SELECT COUNT(*) AS total,
|
"SELECT COUNT(*) AS total,
|
||||||
COALESCE(SUM(vp.completed), 0) AS completed,
|
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
|
WHERE v.is_missing = 0 AND f.is_missing = 0
|
||||||
AND (f.path = ? OR f.path LIKE ?)"
|
AND (f.path = ? OR f.path LIKE ?)"
|
||||||
);
|
);
|
||||||
$stmt->execute([$userId, $folderPath, likePrefix($folderPath) . '/%']);
|
$stmt->execute($args);
|
||||||
$row = $stmt->fetch();
|
$videos = $stmt->fetch();
|
||||||
$total = (int)$row['total'];
|
|
||||||
|
$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 [
|
return [
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
'completed' => (int)$row['completed'],
|
'completed' => (int)$videos['completed'] + (int)$quizzes['completed'],
|
||||||
'percent' => $total > 0 ? (int)round(100 * $row['credit'] / $total) : 0,
|
'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +273,43 @@ function folderVideos(int $folderId, int $userId): array
|
|||||||
return $stmt->fetchAll();
|
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. */
|
/** Breadcrumb chain root -> ... -> folder. */
|
||||||
function folderBreadcrumbs(array $folder): array
|
function folderBreadcrumbs(array $folder): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ if (PHP_SAPI !== 'cli') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stats = scanLibrary();
|
$stats = scanLibrary();
|
||||||
echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos"
|
echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos, {$stats['quizzes']} quizzes"
|
||||||
. " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos flagged missing)\n";
|
. " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos"
|
||||||
|
. " / {$stats['missing_quizzes']} quizzes flagged missing)\n";
|
||||||
|
|||||||
+35
@@ -41,6 +41,41 @@ CREATE TABLE IF NOT EXISTS videos (
|
|||||||
INDEX idx_videos_folder (folder_id)
|
INDEX idx_videos_folder (folder_id)
|
||||||
) ENGINE=InnoDB;
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
-- One row per "*_quiz_solved.txt" file found next to the videos.
|
||||||
|
-- Questions/options are parsed from the file on demand; only the count is
|
||||||
|
-- cached here for progress math.
|
||||||
|
CREATE TABLE IF NOT EXISTS quizzes (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
folder_id INT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL, -- filename without the _quiz_solved.txt suffix
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
path VARCHAR(700) NOT NULL UNIQUE,
|
||||||
|
num_questions INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
is_missing TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_quizzes_folder FOREIGN KEY (folder_id)
|
||||||
|
REFERENCES folders(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_quizzes_folder (folder_id)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
-- Per-user quiz results. completed = every question answered correctly
|
||||||
|
-- in a single attempt (sticky).
|
||||||
|
CREATE TABLE IF NOT EXISTS quiz_progress (
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
quiz_id INT UNSIGNED NOT NULL,
|
||||||
|
best_score INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
last_score INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
total_questions INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
completed TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, quiz_id),
|
||||||
|
CONSTRAINT fk_qp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_qp_quiz FOREIGN KEY (quiz_id) REFERENCES quizzes(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
-- Per-user playback state: resume position + completion flag.
|
-- Per-user playback state: resume position + completion flag.
|
||||||
CREATE TABLE IF NOT EXISTS video_progress (
|
CREATE TABLE IF NOT EXISTS video_progress (
|
||||||
user_id INT UNSIGNED NOT NULL,
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
|||||||
Reference in New Issue
Block a user