// utils/duration.util.js const AVG_WORDS_PER_MINUTE = 200; function stripHtml(html) { // Simple regex strip — no extra dependency needed return (html ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); } /** * Estimate seconds for a single lesson block * Block shape: { id, type, content }, where content.duration_seconds holds * the probed media length for video/audio blocks (see AssetPickerSheet * consumers — VideoBlock/AudioBlock/TextVideoBlock save it on select). */ function estimateBlockDuration(block) { const mediaDuration = Math.round(Number(block.content?.duration_seconds) || 0); // Derive word count from HTML content on the fly const getWordCount = (html) => { const text = stripHtml(html); return text ? text.split(" ").filter(Boolean).length : 0; }; const readingSecs = (html) => Math.ceil((getWordCount(html) / AVG_WORDS_PER_MINUTE) * 60); switch (block.type) { case "text": case "markdown": case "document": return readingSecs(block.content?.body); case "code": return readingSecs(block.content?.code); case "image": return 60; case "video": case "audio": return mediaDuration; case "text-image": case "text_image": return readingSecs(block.content?.body) + 60; case "text-video": case "text_video": return readingSecs(block.content?.body) + mediaDuration; default: return 0; } } /** * Recompute and persist a unit's duration_seconds from the sum of its * non-archived lessons. Used both by recomputeDurations() (a lesson's * content changed) and directly by archive/restore/delete handlers (a * lesson was added to or removed from the "counts toward duration" set * without any of its content changing). */ async function recomputeUnitDuration(unitId) { const Lesson = require("../models/courses/lessons.mdl"); const Unit = require("../models/courses/units.mdl"); const [unitResult] = await Lesson.sequelize.query(` SELECT COALESCE(SUM(l.duration_seconds), 0) AS total FROM lessons l JOIN unit_lessons ul ON ul.lesson_id = l.lesson_id WHERE ul.unit_id = :unitId AND l."deletedAt" IS NULL `, { replacements: { unitId }, type: Lesson.sequelize.QueryTypes.SELECT }); await Unit.update({ duration_seconds: unitResult.total }, { where: { unit_id: unitId } }); } /** * Recompute and persist a course's duration_seconds from the sum of its * non-archived units. See recomputeUnitDuration() above for why this is * split out on its own instead of only living inside recomputeDurations(). */ async function recomputeCourseDuration(courseId) { const Unit = require("../models/courses/units.mdl"); const { Course } = require("../models/courses/courses.mdl"); const [courseResult] = await Unit.sequelize.query(` SELECT COALESCE(SUM(u.duration_seconds), 0) AS total FROM units u JOIN course_units cu ON cu.unit_id = u.unit_id WHERE cu.course_id = :courseId AND u."deletedAt" IS NULL `, { replacements: { courseId }, type: Unit.sequelize.QueryTypes.SELECT }); await Course.update({ duration_seconds: courseResult.total }, { where: { course_id: courseId } }); } /** * Recompute and persist duration_seconds up the chain: * blocks → lesson → every attached unit → every course those units are attached to. * A lesson may live in many units, and a unit in many courses, so all parents refresh. * * `blocksOverride` lets a caller that just wrote the page (e.g. upsertLessonPage, * which already has the upserted row in hand) skip the re-read — avoids relying on * read-after-write visibility of the just-committed upsert. */ async function recomputeDurations(lessonId, blocksOverride = null) { const Lesson = require("../models/courses/lessons.mdl"); const LessonPage = require("../models/courses/lesson_page.mdl"); const UnitLesson = require("../models/courses/unit_lessons.mdl"); const CourseUnit = require("../models/courses/course_units.mdl"); // 1. Lesson duration from blocks let blocks = blocksOverride; if (!blocks) { const page = await LessonPage.findOne({ where: { lesson_id: lessonId } }); blocks = page?.blocks ?? []; } const lessonSecs = blocks.reduce( (sum, block) => sum + estimateBlockDuration(block), 0 ); // Guard against NaN/fractional values before DB write (duration_seconds is INTEGER) const safeLessonSecs = isNaN(lessonSecs) ? 0 : Math.round(lessonSecs); await Lesson.update({ duration_seconds: safeLessonSecs }, { where: { lesson_id: lessonId } }); // 2. Every unit that contains this lesson const unitLinks = await UnitLesson.findAll({ where: { lesson_id: lessonId }, attributes: ["unit_id"] }); const unitIds = [...new Set(unitLinks.map((l) => String(l.unit_id)))]; for (const unitId of unitIds) await recomputeUnitDuration(unitId); // 3. Every course that contains those units if (unitIds.length) { const courseLinks = await CourseUnit.findAll({ where: { unit_id: unitIds }, attributes: ["course_id"] }); const courseIds = [...new Set(courseLinks.map((l) => String(l.course_id)))]; for (const courseId of courseIds) await recomputeCourseDuration(courseId); } } /** * Format seconds → human readable: "1 hr 10 mins", "45 mins", "30 secs" */ function formatDuration(seconds) { if (!seconds) return "0 mins"; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; const parts = []; if (h) parts.push(`${h} hour${h !== 1 ? "s" : ""}`); if (m) parts.push(`${m} minute${m !== 1 ? "s" : ""}`); if (!h && !m && s) parts.push(`${s} second${s !== 1 ? "s" : ""}`); return parts.join(" "); } module.exports = { estimateBlockDuration, recomputeDurations, recomputeUnitDuration, recomputeCourseDuration, formatDuration, };