mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
// 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,
|
||||
};
|
||||
Reference in New Issue
Block a user