Detect course roots per branch and add a category layer above them

The scanner no longer assumes courses sit at a fixed depth. It walks
each branch and marks a folder is_course when it holds media directly
or its children read as chapters (01_, 1. , Module 2, Week 3);
everything above that is a category, and every folder still gets a
course_id pointing at its nearest course for cheap rollups. An empty
.course/.category marker file overrides the heuristic per folder.

Also:
- Roll up progress for the whole library in two grouped queries
  instead of one prefix-matched query per course/folder card.
- Store folders/videos/quizzes paths as utf8mb4_bin so folders that
  differ only in case (CCNP Notes vs CCNP NOtes) aren't merged by a
  case-insensitive UNIQUE key.
- migrateLibrarySchema() brings an older install's tables up to date
  at scan time, so there's no separate migration step.
- Library UI gains a categories view alongside shelves/catalog; the
  continue-watching strip's play button now seeks to the saved
  position on open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FfMPJK7TQV18JhFv6yTKev
This commit is contained in:
root
2026-09-08 18:51:04 +00:00
parent 2fa7452253
commit 6a09ae3833
7 changed files with 849 additions and 115 deletions
+494 -36
View File
@@ -133,6 +133,169 @@ function parseQuizFile(string $absPath): array
* (keyed by relative path so IDs stay stable across rescans), then flags
* rows whose files disappeared as is_missing.
* ------------------------------------------------------------------------- */
/**
* Bring an older install's tables up to date. DDL commits implicitly in
* MariaDB, so this has to run before the scan opens its transaction — never
* from inside one.
*/
function migrateLibrarySchema(PDO $db): void
{
// Where a course starts is worked out per branch at scan time; see
// markCourseRoots(). Both columns are rewritten by every scan.
$db->exec(
'ALTER TABLE folders
ADD COLUMN IF NOT EXISTS is_course TINYINT(1) NOT NULL DEFAULT 0 AFTER sort_order,
ADD COLUMN IF NOT EXISTS course_id INT UNSIGNED NULL AFTER is_course'
);
$db->exec('ALTER TABLE folders ADD INDEX IF NOT EXISTS idx_folders_course (course_id)');
$db->exec('ALTER TABLE folders ADD INDEX IF NOT EXISTS idx_folders_is_course (is_course, is_missing)');
// A path names a file on a case-sensitive filesystem, so "CCNP Notes" and
// "CCNP NOtes" are two directories. Under the schema's default
// (case-insensitive) collation the UNIQUE key on `path` treats them as one
// and the second tree quietly overwrites the first — its videos never get
// indexed. Tightening the collation can only remove collisions, never
// create them, so it is safe to run against existing rows.
foreach (['folders', 'videos', 'quizzes'] as $table) {
$col = $db->query("SHOW FULL COLUMNS FROM `$table` WHERE Field = 'path'")->fetch();
if ($col && $col['Collation'] !== 'utf8mb4_bin') {
$db->exec("ALTER TABLE `$table` MODIFY path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL");
}
}
}
/**
* Does this folder name read as a chapter rather than as a title of its own?
* "01_Welcome", "02 - Creating a VPC", "3. Introduction", "Module 2", "Week 1".
* This is the one signal that separates a course from a category: a folder
* whose children are numbered is the thing those numbers belong to.
*/
function looksOrdered(string $name): bool
{
return (bool)preg_match(
'/^\s*\d{1,3}\s*[-._)\]]'
. '|^\s*\d{1,3}\s+\S'
. '|^\s*(?:module|section|part|week|chapter|day|lesson|lab|unit|vol)[\s_-]*\d+/i',
$name
);
}
/**
* Work out which folders are courses and stamp `is_course` / `course_id`.
*
* Course roots do not sit at a fixed depth. One library can hold
* "System Admin/<course>", "Firewalls/Fortinet/<course>" and
* "Networking/Cisco/CCIE Data Center/DC-Core/<course>" at once, so the level is
* decided per branch, walking down from the top:
*
* - a ".course" / ".category" marker file -> whatever it says
* - nothing to watch in the subtree -> neither, and stop
* - videos or quizzes sitting right here -> course
* - children read as chapters (looksOrdered) -> course
* - otherwise -> category, descend
*
* `$markers` maps a folder path to 'course' or 'category' for the folders that
* carry a marker file. A marker only settles the folder it sits on: once a
* course root is found, everything under it is one of its sections, so to push
* a course root deeper, mark the folder above it ".category".
*
* Everything below a course root carries that root's id in `course_id`, which
* is what lets one grouped query roll up every course at once. Categories above
* every course keep a NULL `course_id` — they are navigation, not material.
*/
function markCourseRoots(PDO $db, array $markers = []): void
{
$folders = [];
$byDepth = []; // deepest first, so a subtree total is complete before its parent needs it
$roots = [];
$stmt = $db->query('SELECT id, parent_id, name, depth, path FROM folders WHERE is_missing = 0
ORDER BY depth DESC');
foreach ($stmt as $r) {
$id = (int)$r['id'];
$folders[$id] = [
'parent' => $r['parent_id'] !== null ? (int)$r['parent_id'] : null,
'name' => $r['name'],
'marker' => $markers[$r['path']] ?? null,
'direct' => 0, // videos + quizzes in this folder itself
'subtree' => 0, // ... and in everything under it
'kids' => [],
];
$byDepth[] = $id;
}
foreach (['videos', 'quizzes'] as $table) {
$rows = $db->query("SELECT folder_id, COUNT(*) AS n FROM `$table`
WHERE is_missing = 0 GROUP BY folder_id");
foreach ($rows as $r) {
$id = (int)$r['folder_id'];
if (isset($folders[$id])) {
$folders[$id]['direct'] += (int)$r['n'];
}
}
}
foreach ($folders as $id => $f) {
if ($f['parent'] !== null && isset($folders[$f['parent']])) {
$folders[$f['parent']]['kids'][] = $id;
} else {
$roots[] = $id;
}
}
foreach ($byDepth as $id) {
$folders[$id]['subtree'] += $folders[$id]['direct'];
$parent = $folders[$id]['parent'];
if ($parent !== null && isset($folders[$parent])) {
$folders[$parent]['subtree'] += $folders[$id]['subtree'];
}
}
$isCourse = [];
$courseOf = [];
$stack = array_map(fn($id) => [$id, null], $roots);
while ($stack) {
[$id, $course] = array_pop($stack);
$f = $folders[$id];
if ($course !== null) { // already inside a course: a section
$isCourse[$id] = 0;
$courseOf[$id] = $course;
} elseif ($f['subtree'] === 0) { // no videos, no quizzes, nothing to show
$isCourse[$id] = 0;
$courseOf[$id] = null;
} elseif ($f['marker'] === 'course') { // told, not guessed
$isCourse[$id] = 1;
$courseOf[$id] = $id;
} else {
$live = array_values(array_filter($f['kids'], fn($k) => $folders[$k]['subtree'] > 0));
$chaptered = false;
foreach ($live as $k) {
if (looksOrdered($folders[$k]['name'])) { $chaptered = true; break; }
}
// ".category" says "the courses are further down" — but a folder
// holding videos itself has to be a course or they vanish.
$forcedCategory = $f['marker'] === 'category' && $f['direct'] === 0 && $live;
if (!$forcedCategory && ($f['direct'] > 0 || $chaptered || !$live)) {
$isCourse[$id] = 1;
$courseOf[$id] = $id; // and everything below inherits it
} else {
$isCourse[$id] = 0;
$courseOf[$id] = null;
}
}
foreach ($f['kids'] as $k) {
$stack[] = [$k, $courseOf[$id]];
}
}
$db->exec('UPDATE folders SET is_course = 0, course_id = NULL
WHERE is_course <> 0 OR course_id IS NOT NULL');
$upd = $db->prepare('UPDATE folders SET is_course = ?, course_id = ? WHERE id = ?');
foreach ($courseOf as $id => $course) {
if ($course === null && !$isCourse[$id]) {
continue; // already reset above
}
$upd->execute([$isCourse[$id], $course, $id]);
}
}
function scanLibrary(): array
{
$root = rtrim(VIDEOS_ROOT, '/');
@@ -158,11 +321,14 @@ function scanLibrary(): array
/** The scan proper — only ever called by scanLibrary() while it holds the lock. */
function scanLibraryLocked(PDO $db, string $root): array
{
migrateLibrarySchema($db); // DDL: must happen before the transaction opens
$stats = ['folders' => 0, 'videos' => 0, 'quizzes' => 0,
'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0];
$seenFolderIds = [];
$seenVideoIds = [];
$seenQuizIds = [];
$markers = []; // folder path => 'course' | 'category', from marker files
$upsertFolder = $db->prepare(
'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing)
@@ -197,9 +363,19 @@ function scanLibraryLocked(PDO $db, string $root): array
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth)
use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz,
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void {
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds, &$markers): void {
$entries = scandir($dir) ?: [];
// An empty ".course" / ".category" file overrides what markCourseRoots()
// would have guessed for this folder — the escape hatch for material the
// chapter-name heuristic reads wrong. Dot files are skipped below, so
// they are looked for before the filter.
foreach (['course', 'category'] as $marker) {
if ($relBase !== '' && is_file($dir . '/.' . $marker)) {
$markers[$relBase] = $marker;
break; // ".course" wins if somebody wrote both
}
}
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
usort($entries, 'strnatcasecmp'); // "Video2" before "Video10"
@@ -258,6 +434,14 @@ function scanLibraryLocked(PDO $db, string $root): array
$stats['missing_videos'] = $flagMissing('videos', $seenVideoIds);
$stats['missing_quizzes'] = $flagMissing('quizzes', $seenQuizIds);
// Which folders are courses can only be decided once the whole tree and
// its contents are known, so it runs last — inside the same transaction,
// so no request ever sees a library with no courses in it.
markCourseRoots($db, $markers);
$stats['courses'] = (int)$db->query(
'SELECT COUNT(*) FROM folders WHERE is_course = 1 AND is_missing = 0'
)->fetchColumn();
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
@@ -341,18 +525,78 @@ function folderRollup(int $userId, string $folderPath): array
$stmt->execute($args);
$quizzes = $stmt->fetch();
$total = (int)$videos['total'] + (int)$quizzes['total'];
$credit = $videos['credit'] + $quizzes['credit'];
return composeRollup($videos, $quizzes);
}
return [
'videos' => (int)$videos['total'],
'quizzes' => (int)$quizzes['total'],
'duration' => (float)$videos['duration'],
'bytes' => (int)$videos['bytes'],
'total' => $total,
'completed' => (int)$videos['completed'] + (int)$quizzes['completed'],
'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0,
];
/**
* folderRollup() for every child of one folder, in two queries instead of two
* per child — a course with thirty sections, or a category with thirty courses,
* is one page either way.
*
* Keyed by child folder *name*, which the filesystem already guarantees is
* unique inside a directory. Children with nothing under them are absent.
*/
function childRollups(int $userId, string $folderPath): array
{
// The first path segment below $folderPath: "Course/Sec/Lesson" -> "Sec".
$seg = "SUBSTRING_INDEX(SUBSTRING(f.path, CHAR_LENGTH(?) + 2), '/', 1)";
$args = fn() => [$folderPath, $userId, $folderPath, likePrefix($folderPath) . '/%'];
$stmt = db()->prepare(
"SELECT $seg AS seg,
COUNT(*) AS total,
COALESCE(SUM(COALESCE(v.duration_seconds, vp.duration_seconds)), 0) AS duration,
COALESCE(SUM(v.size_bytes), 0) AS bytes,
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 <> ? AND f.path LIKE ?
GROUP BY seg"
);
$stmt->execute($args());
$videos = [];
foreach ($stmt as $r) { $videos[$r['seg']] = $r; }
$stmt = db()->prepare(
"SELECT $seg AS seg,
COUNT(*) AS total,
0 AS duration, 0 AS bytes,
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 <> ? AND f.path LIKE ?
GROUP BY seg"
);
$stmt->execute($args());
$quizzes = [];
foreach ($stmt as $r) { $quizzes[$r['seg']] = $r; }
$blank = ['total' => 0, 'duration' => 0, 'bytes' => 0, 'completed' => 0, 'credit' => 0];
$out = [];
foreach (array_keys($videos + $quizzes) as $name) {
$out[$name] = composeRollup($videos[$name] ?? $blank, $quizzes[$name] ?? $blank);
}
return $out;
}
/**
@@ -366,12 +610,112 @@ function folderProgress(int $userId, string $folderPath): array
return ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $r['percent']];
}
/** A course (or section) as the library screens show it: contents + progress. */
function courseSummary(int $userId, array $folder): array
/** An empty rollup, for a folder that holds nothing (yet). */
function emptyRollup(): array
{
$r = folderRollup($userId, $folder['path']);
$percent = $r['percent'];
return ['videos' => 0, 'quizzes' => 0, 'duration' => 0.0, 'bytes' => 0,
'total' => 0, 'completed' => 0, 'percent' => 0];
}
/** Fold the two halves of a rollup (videos, quizzes) into the public shape. */
function composeRollup(array $v, array $q): array
{
$total = (int)$v['total'] + (int)$q['total'];
$credit = (float)$v['credit'] + (float)$q['credit'];
return [
'videos' => (int)$v['total'],
'quizzes' => (int)$q['total'],
'duration' => (float)$v['duration'],
'bytes' => (int)$v['bytes'],
'total' => $total,
'completed' => (int)$v['completed'] + (int)$q['completed'],
'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0,
];
}
/** Add one rollup into another (used to total a category from its courses). */
function addRollup(array $a, array $b): array
{
foreach (['videos', 'quizzes', 'duration', 'bytes', 'total', 'completed'] as $k) {
$a[$k] += $b[$k];
}
$a['_credit'] = ($a['_credit'] ?? 0) + ($b['_credit'] ?? 0);
$a['percent'] = $a['total'] > 0 ? (int)round(100 * $a['_credit'] / $a['total']) : 0;
return $a;
}
/**
* folderRollup() for every course at once, keyed by course id.
*
* A per-course card used to cost two prefix-matched queries. That was fine when
* a course meant a top-level folder and there were a handful; with course roots
* found at their real depth there are hundreds, and 2N queries per page load is
* not a thing worth doing. `folders.course_id` turns the prefix match into a
* GROUP BY, so the whole library is two queries.
*/
function courseRollups(int $userId): array
{
$videos = db()->prepare(
"SELECT f.course_id AS cid,
COUNT(*) AS total,
COALESCE(SUM(COALESCE(v.duration_seconds, vp.duration_seconds)), 0) AS duration,
COALESCE(SUM(v.size_bytes), 0) AS bytes,
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.course_id IS NOT NULL
GROUP BY f.course_id"
);
$videos->execute([$userId]);
$quizzes = db()->prepare(
"SELECT f.course_id AS cid,
COUNT(*) AS total,
0 AS duration, 0 AS bytes,
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.course_id IS NOT NULL
GROUP BY f.course_id"
);
$quizzes->execute([$userId]);
$blank = ['total' => 0, 'duration' => 0, 'bytes' => 0, 'completed' => 0, 'credit' => 0];
$v = $q = [];
foreach ($videos as $r) { $v[(int)$r['cid']] = $r; }
foreach ($quizzes as $r) { $q[(int)$r['cid']] = $r; }
$out = [];
foreach (array_keys($v + $q) as $cid) {
$half = composeRollup($v[$cid] ?? $blank, $q[$cid] ?? $blank);
$half['_credit'] = (float)($v[$cid]['credit'] ?? 0) + (float)($q[$cid]['credit'] ?? 0);
$out[$cid] = $half;
}
return $out;
}
/** The card shape the library screens render, from a folder row and its rollup. */
function summaryFrom(array $folder, array $r): array
{
$percent = $r['percent'];
return [
'id' => (int)$folder['id'],
'name' => $folder['name'],
@@ -382,16 +726,85 @@ function courseSummary(int $userId, array $folder): array
'bytes' => $r['bytes'],
'added_at' => $folder['created_at'] ?? null,
'progress' => ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $percent],
// The shelves the library groups by — the app has no course categories,
// so progress is what separates one shelf from another.
// The shelves the library groups by — courses carry no categories of
// their own, so progress is what separates one shelf from another.
'state' => $percent >= 100 ? 'finished' : ($percent > 0 ? 'progress' : 'fresh'),
];
}
/** Every top-level folder as a course card. */
/** A course (or section) as the library screens show it: contents + progress. */
function courseSummary(int $userId, array $folder): array
{
return summaryFrom($folder, folderRollup($userId, $folder['path']));
}
/** Every folder the scanner marked as a course root, shallowest first. */
function courseFolders(): array
{
return db()->query(
'SELECT * FROM folders WHERE is_course = 1 AND is_missing = 0 ORDER BY depth, path'
)->fetchAll();
}
/** Every course in the library as a card. Two queries for the lot. */
function courseCards(int $userId): array
{
return array_map(fn($f) => courseSummary($userId, $f), childFolders(null));
$rollups = courseRollups($userId);
return array_map(
fn($f) => summaryFrom($f, $rollups[(int)$f['id']] ?? emptyRollup()),
courseFolders()
);
}
/**
* The folders that sit above courses: the library's navigation tier.
* `$parentId` null lists the top of the tree. Each card totals the courses
* beneath it, so a category shows real progress rather than a flat 0 %.
*/
function categoryCards(int $userId, ?int $parentId = null, ?array $rollups = null): array
{
$rollups ??= courseRollups($userId);
$courses = courseFolders();
$out = [];
foreach (childFolders($parentId) as $f) {
if ((int)$f['is_course'] === 1) {
continue; // a course, not a category
}
$prefix = $f['path'] . '/';
$total = emptyRollup();
$count = 0;
foreach ($courses as $c) {
if (str_starts_with($c['path'], $prefix)) {
$total = addRollup($total, $rollups[(int)$c['id']] ?? emptyRollup());
$count++;
}
}
if ($count === 0) {
continue; // nothing watchable under it
}
$card = summaryFrom($f, $total);
$card['kind'] = 'category';
$card['courses'] = $count;
$out[] = $card;
}
return $out;
}
/** Courses that live directly under $parentId (null = the top of the tree). */
function courseCardsUnder(int $userId, ?int $parentId = null, ?array $rollups = null): array
{
$rollups ??= courseRollups($userId);
$out = [];
foreach (childFolders($parentId) as $f) {
if ((int)$f['is_course'] !== 1) {
continue;
}
$card = summaryFrom($f, $rollups[(int)$f['id']] ?? emptyRollup());
$card['kind'] = 'course';
$out[] = $card;
}
return $out;
}
/** Headline numbers for the home screen, computed from the course cards. */
@@ -439,19 +852,20 @@ function userStats(int $userId, array $courses): array
}
/** Courses the scanner indexed most recently — the "found in the last scan" strip. */
function recentCourses(int $userId, int $limit = 3): array
function recentCourses(int $userId, int $limit = 3, ?array $rollups = null): array
{
$rollups ??= courseRollups($userId);
$stmt = db()->prepare(
'SELECT * FROM folders
WHERE parent_id IS NULL AND is_missing = 0
WHERE is_course = 1 AND is_missing = 0
ORDER BY created_at DESC, id DESC
LIMIT ' . (int)$limit
);
$stmt->execute();
$week = time() - 7 * 86400;
return array_map(function ($f) use ($userId, $week) {
$c = courseSummary($userId, $f);
return array_map(function ($f) use ($rollups, $week) {
$c = summaryFrom($f, $rollups[(int)$f['id']] ?? emptyRollup());
$c['is_new'] = $f['created_at'] !== null && strtotime($f['created_at']) >= $week;
return $c;
}, $stmt->fetchAll());
@@ -461,19 +875,34 @@ function recentCourses(int $userId, int $limit = 3): array
function libraryTotals(): array
{
$row = db()->query(
'SELECT (SELECT COUNT(*) FROM folders WHERE parent_id IS NULL AND is_missing = 0) AS courses,
'SELECT (SELECT COUNT(*) FROM folders WHERE is_course = 1 AND is_missing = 0) AS courses,
(SELECT COUNT(*) FROM videos WHERE is_missing = 0) AS videos,
(SELECT COUNT(*) FROM quizzes WHERE is_missing = 0) AS quizzes,
(SELECT COALESCE(SUM(size_bytes), 0) FROM videos WHERE is_missing = 0) AS bytes,
(SELECT COALESCE(SUM(duration_seconds), 0) FROM videos WHERE is_missing = 0) AS duration'
)->fetch();
// Only the categories that actually lead somewhere: a folder with no course
// anywhere beneath it (a stray "Books" of PDFs) is not part of the library.
$categories = (int)db()->query(
'WITH RECURSIVE ancestors AS (
SELECT parent_id AS id FROM folders
WHERE is_course = 1 AND is_missing = 0 AND parent_id IS NOT NULL
UNION
SELECT f.parent_id FROM folders f
JOIN ancestors a ON f.id = a.id
WHERE f.parent_id IS NOT NULL
)
SELECT COUNT(DISTINCT id) FROM ancestors'
)->fetchColumn();
return [
'courses' => (int)$row['courses'],
'videos' => (int)$row['videos'],
'quizzes' => (int)$row['quizzes'],
'bytes' => (int)$row['bytes'],
'duration' => (float)$row['duration'],
'courses' => (int)$row['courses'],
'categories' => $categories,
'videos' => (int)$row['videos'],
'quizzes' => (int)$row['quizzes'],
'bytes' => (int)$row['bytes'],
'duration' => (float)$row['duration'],
];
}
@@ -594,6 +1023,11 @@ function resetProgress(int $userId, string $scope, int $id = 0): array
/* ---------------------------------------------------------------------------
* Search — substring match on names (and on the relative path, so
* "Course 1/Intro" finds everything under it).
*
* `path` is stored utf8mb4_bin so that two folders differing only in case stay
* two folders. That makes LIKE on it case-sensitive, which is right for
* identifying a file and wrong for a search box, so the search — and only the
* search — asks for the case-insensitive collation back.
* ------------------------------------------------------------------------- */
function searchFolders(string $term, int $limit = 25): array
@@ -601,7 +1035,8 @@ function searchFolders(string $term, int $limit = 25): array
$like = likeContains($term);
$stmt = db()->prepare(
'SELECT * FROM folders
WHERE is_missing = 0 AND (name LIKE ? OR path LIKE ?)
WHERE is_missing = 0
AND (name LIKE ? OR path COLLATE utf8mb4_unicode_ci LIKE ?)
ORDER BY depth, path
LIMIT ' . (int)$limit
);
@@ -618,7 +1053,8 @@ function searchVideos(int $userId, string $term, int $limit = 50): array
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 (v.name LIKE ? OR v.path LIKE ?)
WHERE v.is_missing = 0 AND f.is_missing = 0
AND (v.name LIKE ? OR v.path COLLATE utf8mb4_unicode_ci LIKE ?)
ORDER BY f.path, v.sort_order, v.name
LIMIT ' . (int)$limit
);
@@ -635,7 +1071,8 @@ function searchQuizzes(int $userId, string $term, int $limit = 25): array
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 (q.name LIKE ? OR q.path LIKE ?)
WHERE q.is_missing = 0 AND f.is_missing = 0
AND (q.name LIKE ? OR q.path COLLATE utf8mb4_unicode_ci LIKE ?)
ORDER BY f.path, q.sort_order, q.name
LIMIT ' . (int)$limit
);
@@ -807,11 +1244,32 @@ function continueWatching(int $userId, int $limit = 6): array
return $stmt->fetchAll();
}
/** The top-level course a folder belongs to (itself, if it's already top-level). */
/**
* The course a folder belongs to — itself if it is one, otherwise the course
* root above it. Stamped on every folder by the scanner, so this is a lookup
* rather than a walk; a category above every course has none, and answers with
* its own id so callers always get something to link to.
*/
function courseIdOf(array $folder): int
{
$crumbs = folderBreadcrumbs($folder);
return (int)$crumbs[0]['id'];
return $folder['course_id'] !== null ? (int)$folder['course_id'] : (int)$folder['id'];
}
/** Is this folder one the library presents as a course? */
function isCourseFolder(array $folder): bool
{
return (int)($folder['is_course'] ?? 0) === 1;
}
/**
* What a folder is, for the screen that has to draw it: a course root, a
* section inside one, or a category of courses above them.
*/
function folderKind(array $folder): string
{
if (isCourseFolder($folder)) return 'course';
if ($folder['course_id'] !== null) return 'section';
return 'category';
}
function formatDuration(?float $seconds): string