mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-08-11 03:17:18 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export interface CourseTitleLike {
|
||||
name: string
|
||||
displayName?: string | null
|
||||
}
|
||||
|
||||
export function getCourseDisplayName(course: CourseTitleLike): string {
|
||||
return course.displayName?.trim() || course.name
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { readFile, readdir, stat } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
export const COURSE_METADATA_FILENAMES = [
|
||||
'offlineacademy.metadata.json',
|
||||
'course.metadata.json',
|
||||
'metadata.json',
|
||||
'.offlineacademy.json',
|
||||
]
|
||||
|
||||
export const MODULE_METADATA_FILENAMES = [
|
||||
'offlineacademy.metadata.json',
|
||||
'module.metadata.json',
|
||||
'metadata.json',
|
||||
'.offlineacademy.json',
|
||||
]
|
||||
|
||||
const ALL_METADATA_FILENAMES = new Set([
|
||||
...COURSE_METADATA_FILENAMES,
|
||||
...MODULE_METADATA_FILENAMES,
|
||||
])
|
||||
|
||||
export type CourseMetadataInput = {
|
||||
title?: unknown
|
||||
displayName?: unknown
|
||||
description?: unknown
|
||||
thumbnail?: unknown
|
||||
tags?: unknown
|
||||
categories?: unknown
|
||||
favorited?: unknown
|
||||
favorite?: unknown
|
||||
}
|
||||
|
||||
export type ApplyCourseMetadataResult = {
|
||||
appliedFields: string[]
|
||||
skippedFields: string[]
|
||||
tagsAdded: string[]
|
||||
source: string
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function normalizeStringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(asTrimmedString)
|
||||
.filter((item): item is string => Boolean(item))
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeMetadata(input: CourseMetadataInput) {
|
||||
const displayName = asTrimmedString(input.displayName) || asTrimmedString(input.title)
|
||||
const description = asTrimmedString(input.description)
|
||||
const thumbnail = asTrimmedString(input.thumbnail)
|
||||
const tags = normalizeStringList(input.tags)
|
||||
const categories = normalizeStringList(input.categories).map(category => (
|
||||
category.startsWith('category:') ? category : `category:${category}`
|
||||
))
|
||||
const favorited = typeof input.favorited === 'boolean'
|
||||
? input.favorited
|
||||
: typeof input.favorite === 'boolean'
|
||||
? input.favorite
|
||||
: undefined
|
||||
|
||||
return {
|
||||
displayName,
|
||||
description,
|
||||
thumbnail,
|
||||
tags: Array.from(new Set([...tags, ...categories])),
|
||||
favorited,
|
||||
}
|
||||
}
|
||||
|
||||
export function isMetadataFileName(fileName: string) {
|
||||
return ALL_METADATA_FILENAMES.has(fileName.toLowerCase())
|
||||
}
|
||||
|
||||
export async function readCourseMetadataFile(dirPath: string, fileNames = COURSE_METADATA_FILENAMES) {
|
||||
for (const fileName of fileNames) {
|
||||
const fullPath = join(dirPath, fileName)
|
||||
try {
|
||||
const fileStat = await stat(fullPath)
|
||||
if (!fileStat.isFile()) continue
|
||||
const raw = await readFile(fullPath, 'utf8')
|
||||
return {
|
||||
fileName,
|
||||
path: fullPath,
|
||||
metadata: JSON.parse(raw) as CourseMetadataInput,
|
||||
}
|
||||
} catch {
|
||||
// Try the next supported filename.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function readModuleMetadataFiles(coursePath: string) {
|
||||
const results: Array<{ moduleName: string; fileName: string; path: string; metadata: CourseMetadataInput }> = []
|
||||
|
||||
try {
|
||||
const entries = await readdir(coursePath, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
||||
const found = await readCourseMetadataFile(join(coursePath, entry.name), MODULE_METADATA_FILENAMES)
|
||||
if (found) {
|
||||
results.push({ moduleName: entry.name, ...found })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return results
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export async function applyCourseMetadata(courseId: string, metadata: CourseMetadataInput, source: string): Promise<ApplyCourseMetadataResult> {
|
||||
const normalized = normalizeMetadata(metadata)
|
||||
const current = await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
description: true,
|
||||
thumbnail: true,
|
||||
favorited: true,
|
||||
courseTags: { include: { tag: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!current) {
|
||||
return { appliedFields: [], skippedFields: ['course:not-found'], tagsAdded: [], source }
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
const appliedFields: string[] = []
|
||||
const skippedFields: string[] = []
|
||||
|
||||
if (normalized.displayName) {
|
||||
if (!current.displayName?.trim()) {
|
||||
data.displayName = normalized.displayName
|
||||
appliedFields.push('displayName')
|
||||
} else {
|
||||
skippedFields.push('displayName')
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.description) {
|
||||
if (!current.description?.trim()) {
|
||||
data.description = normalized.description
|
||||
appliedFields.push('description')
|
||||
} else {
|
||||
skippedFields.push('description')
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.thumbnail) {
|
||||
if (!current.thumbnail?.trim()) {
|
||||
data.thumbnail = normalized.thumbnail
|
||||
appliedFields.push('thumbnail')
|
||||
} else {
|
||||
skippedFields.push('thumbnail')
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(data).length > 0) {
|
||||
await prisma.course.update({ where: { id: courseId }, data })
|
||||
}
|
||||
|
||||
const existingTagNames = new Set(current.courseTags.map(courseTag => courseTag.tag.name.toLowerCase()))
|
||||
const tagsAdded: string[] = []
|
||||
|
||||
for (const tagName of normalized.tags) {
|
||||
if (existingTagNames.has(tagName.toLowerCase())) continue
|
||||
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { name: tagName },
|
||||
update: {},
|
||||
create: { name: tagName },
|
||||
})
|
||||
|
||||
await prisma.courseTag.upsert({
|
||||
where: { courseId_tagId: { courseId, tagId: tag.id } },
|
||||
update: {},
|
||||
create: { courseId, tagId: tag.id },
|
||||
})
|
||||
|
||||
tagsAdded.push(tagName)
|
||||
}
|
||||
|
||||
return { appliedFields, skippedFields, tagsAdded, source }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { join, extname, basename } from 'path'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
const LOCAL_THUMBNAIL_NAMES = ['cover', 'thumbnail']
|
||||
const LOCAL_THUMBNAIL_EXTS = ['.jpg', '.jpeg', '.png', '.webp']
|
||||
|
||||
const COVER_GRADIENTS = [
|
||||
'bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500',
|
||||
'bg-gradient-to-br from-blue-500 via-cyan-500 to-teal-500',
|
||||
'bg-gradient-to-br from-emerald-500 via-green-500 to-lime-500',
|
||||
'bg-gradient-to-br from-orange-500 via-red-500 to-rose-500',
|
||||
'bg-gradient-to-br from-violet-500 via-fuchsia-500 to-pink-500',
|
||||
'bg-gradient-to-br from-sky-500 via-blue-500 to-indigo-500',
|
||||
'bg-gradient-to-br from-amber-500 via-orange-500 to-red-500',
|
||||
'bg-gradient-to-br from-teal-500 via-emerald-500 to-green-500',
|
||||
]
|
||||
|
||||
export function getDeterministicGradient(title: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < title.length; i++) {
|
||||
hash = ((hash << 5) - hash + title.charCodeAt(i)) | 0
|
||||
}
|
||||
const index = Math.abs(hash) % COVER_GRADIENTS.length
|
||||
return COVER_GRADIENTS[index]
|
||||
}
|
||||
|
||||
export async function getLocalCourseThumbnailPath(coursePath: string): Promise<string | null> {
|
||||
try {
|
||||
const entries = await readdir(coursePath, { withFileTypes: true })
|
||||
const files = entries.filter(e => e.isFile())
|
||||
|
||||
for (const baseName of LOCAL_THUMBNAIL_NAMES) {
|
||||
for (const ext of LOCAL_THUMBNAIL_EXTS) {
|
||||
const match = files.find(f => f.name.toLowerCase() === `${baseName}${ext}`)
|
||||
if (match) {
|
||||
return join(coursePath, match.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// coursePath may not exist yet or be unreadable
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function ensurePublicThumbnail(
|
||||
courseSlug: string,
|
||||
sourcePath: string,
|
||||
): Promise<string | null> {
|
||||
const ext = extname(sourcePath).toLowerCase()
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return null
|
||||
|
||||
const thumbDir = join(process.cwd(), 'public', 'thumbnails', 'courses')
|
||||
if (!existsSync(thumbDir)) {
|
||||
await mkdir(thumbDir, { recursive: true })
|
||||
}
|
||||
|
||||
const targetName = `${courseSlug}-thumb${ext}`
|
||||
const targetPath = join(thumbDir, targetName)
|
||||
|
||||
try {
|
||||
const sourceStat = await stat(sourcePath)
|
||||
const targetStat = await stat(targetPath).catch(() => null)
|
||||
if (!targetStat || sourceStat.mtimeMs !== targetStat.mtimeMs || sourceStat.size !== targetStat.size) {
|
||||
const { copyFile } = await import('fs/promises')
|
||||
await copyFile(sourcePath, targetPath)
|
||||
const targetStat2 = await stat(targetPath)
|
||||
await stat(targetPath) // ensure exists
|
||||
try {
|
||||
// touch target mtime to match source so next run can diff by mtime
|
||||
const { utimes } = await import('fs/promises')
|
||||
await utimes(targetPath, new Date(sourceStat.mtimeMs), new Date(sourceStat.mtimeMs))
|
||||
} catch {
|
||||
// ignore utimes failure; content copy is what matters
|
||||
}
|
||||
}
|
||||
return `/thumbnails/courses/${targetName}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getThumbnailPriority(coursePath: string): 'local' | 'downloaded' | 'gradient' {
|
||||
return 'local'
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const COVER_GRADIENTS = [
|
||||
'bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500',
|
||||
'bg-gradient-to-br from-blue-500 via-cyan-500 to-teal-500',
|
||||
'bg-gradient-to-br from-emerald-500 via-green-500 to-lime-500',
|
||||
'bg-gradient-to-br from-orange-500 via-red-500 to-rose-500',
|
||||
'bg-gradient-to-br from-violet-500 via-fuchsia-500 to-pink-500',
|
||||
'bg-gradient-to-br from-sky-500 via-blue-500 to-indigo-500',
|
||||
'bg-gradient-to-br from-amber-500 via-orange-500 to-red-500',
|
||||
'bg-gradient-to-br from-teal-500 via-emerald-500 to-green-500',
|
||||
]
|
||||
|
||||
export function getDeterministicGradient(title: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < title.length; i++) {
|
||||
hash = ((hash << 5) - hash + title.charCodeAt(i)) | 0
|
||||
}
|
||||
const index = Math.abs(hash) % COVER_GRADIENTS.length
|
||||
return COVER_GRADIENTS[index]
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface Course {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
thumbnail: string | null
|
||||
description: string | null
|
||||
path: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
_count: { modules: number; lessons: number }
|
||||
progress: {
|
||||
completedLessons: number
|
||||
totalLessons: number
|
||||
percentage: number
|
||||
lastWatched: string | null
|
||||
}
|
||||
coverClass?: string
|
||||
}
|
||||
|
||||
interface CoursesResponse {
|
||||
courses: any[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface UseCoursesOptions {
|
||||
initialPage?: number
|
||||
initialLimit?: number
|
||||
initialSearch?: string
|
||||
initialFilter?: string
|
||||
initialSortBy?: string
|
||||
initialSortOrder?: string
|
||||
}
|
||||
|
||||
export function useCourses(options: UseCoursesOptions = {}) {
|
||||
const [courses, setCourses] = useState<any[]>([])
|
||||
const [tags, setTags] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pagination, setPagination] = useState({
|
||||
page: options.initialPage || 1,
|
||||
limit: options.initialLimit || 10,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNext: false,
|
||||
hasPrev: false,
|
||||
})
|
||||
const [search, setSearch] = useState(options.initialSearch || '')
|
||||
const [filter, setFilter] = useState(options.initialFilter || 'all')
|
||||
const [sortBy, setSortBy] = useState(options.initialSortBy || 'updatedAt')
|
||||
const [sortOrder, setSortOrder] = useState(options.initialSortOrder || 'desc')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(options.initialSearch || '')
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(search)
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [search])
|
||||
|
||||
const fetchCourses = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
search: debouncedSearch,
|
||||
filter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/courses?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch courses')
|
||||
}
|
||||
const data = await response.json()
|
||||
setCourses(data.courses)
|
||||
setTags(data.tags || [])
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.pagination.total,
|
||||
totalPages: data.pagination.totalPages,
|
||||
hasNext: data.pagination.hasNext,
|
||||
hasPrev: data.pagination.hasPrev,
|
||||
}))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch courses')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [pagination.page, pagination.limit, debouncedSearch, filter, sortBy, sortOrder])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourses()
|
||||
}, [fetchCourses])
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
setPagination(prev => ({ ...prev, page }))
|
||||
}
|
||||
|
||||
const nextPage = () => {
|
||||
if (pagination.hasNext) {
|
||||
setPagination(prev => ({ ...prev, page: prev.page + 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
const prevPage = () => {
|
||||
if (pagination.hasPrev) {
|
||||
setPagination(prev => ({ ...prev, page: prev.page - 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
const changeLimit = (limit: number) => {
|
||||
setPagination(prev => ({ ...prev, limit, page: 1 }))
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearch(value)
|
||||
}
|
||||
|
||||
const handleFilter = (value: string) => {
|
||||
setFilter(value)
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
}
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
setSortBy(field)
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
}
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
}
|
||||
|
||||
return {
|
||||
courses,
|
||||
tags,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
search,
|
||||
setSearch: handleSearch,
|
||||
filter,
|
||||
setFilter: handleFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
setSortBy: handleSort,
|
||||
setSortOrder: toggleSortOrder,
|
||||
toggleSortOrder,
|
||||
goToPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
changeLimit,
|
||||
refetch: fetchCourses,
|
||||
}
|
||||
}
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
@@ -0,0 +1,48 @@
|
||||
export type QuizSource = 'the-trivia-api' | 'quizapi'
|
||||
|
||||
export type QuizQuestion = {
|
||||
id: string
|
||||
prompt: string
|
||||
options: string[]
|
||||
answerIndex: number
|
||||
explanation?: string
|
||||
}
|
||||
|
||||
export type QuizCache = {
|
||||
version: 1
|
||||
source: QuizSource
|
||||
topic: string
|
||||
title: string
|
||||
description: string
|
||||
fetchedAt: string
|
||||
updatedAt: string
|
||||
questions: QuizQuestion[]
|
||||
}
|
||||
|
||||
export type QuizAttemptResult = {
|
||||
score: number
|
||||
totalQuestions: number
|
||||
passed: boolean
|
||||
}
|
||||
|
||||
export function cleanQuizTopic(input: string) {
|
||||
return input
|
||||
.replace(/quiz[_-]?cache/gi, '')
|
||||
.replace(/^\d+[\s._-]*/g, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function titleCase(input: string) {
|
||||
return input
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function normalizeQuizTopic(input: string) {
|
||||
const cleaned = cleanQuizTopic(input)
|
||||
return cleaned.length > 0 ? titleCase(cleaned) : 'General Knowledge'
|
||||
}
|
||||
+554
@@ -0,0 +1,554 @@
|
||||
import 'server-only'
|
||||
|
||||
import { mkdir, readFile, writeFile, readdir } from 'fs/promises'
|
||||
import { dirname, join, relative, basename } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { QuizCache, QuizQuestion, QuizSource } from './quiz-types'
|
||||
import { normalizeQuizTopic } from './quiz-types'
|
||||
|
||||
const QUIZ_CACHE_FILE = 'quiz_cache.json'
|
||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.webm', '.mov', '.avi', '.m4v', '.flv', '.wmv'])
|
||||
const ONLINE_QUIZ_SOURCES = new Set<QuizSource>(['the-trivia-api', 'quizapi'])
|
||||
|
||||
async function getQuizApiKey(): Promise<string | null> {
|
||||
const setting = await prisma.setting.findUnique({ where: { key: 'quizApiKey' } })
|
||||
return setting?.value || null
|
||||
}
|
||||
|
||||
function introCandidate(input: string) {
|
||||
return normalizeQuizTopic(input)
|
||||
.toLowerCase()
|
||||
.replace(/^\d+[\s._-]*/g, '')
|
||||
.replace(/^s\d+[\s._-]*/g, '')
|
||||
.replace(/\[[^\]]+\]/g, ' ')
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function isIntroQuizTopic(input: string) {
|
||||
const value = introCandidate(input)
|
||||
return /(^|\b)(intro|introduction|introduce|introducing|welcome|overview|course overview|course introduction|getting started|getting started overview|websites you may like|exercise files|bonus lecture|conclusion|footnote|footnotes|endnote|endnotes|appendix|appendices|bibliography|references|notes|supplemental)(\b|$)/.test(value)
|
||||
}
|
||||
|
||||
function isOnlineQuizSource(source: unknown): source is QuizSource {
|
||||
return typeof source === 'string' && ONLINE_QUIZ_SOURCES.has(source as QuizSource)
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(input: string) {
|
||||
return input
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function shuffleArray<T>(items: T[]) {
|
||||
const copy = [...items]
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[copy[i], copy[j]] = [copy[j], copy[i]]
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
function buildQuizTitle(topic: string) {
|
||||
const displayTopic = topic.split(' | ')[0].split(' - ')[0]
|
||||
return `Quiz: ${normalizeQuizTopic(displayTopic)}`
|
||||
}
|
||||
|
||||
function buildQuizDescription(topic: string, source: QuizSource) {
|
||||
return `Auto-generated practice quiz for ${normalizeQuizTopic(topic)} from ${source.toUpperCase()}.`
|
||||
}
|
||||
|
||||
function triviaQuestionText(value: unknown) {
|
||||
if (typeof value === 'string') return value
|
||||
if (value && typeof value === 'object' && 'text' in value) {
|
||||
return String((value as { text?: unknown }).text || 'Question')
|
||||
}
|
||||
return 'Question'
|
||||
}
|
||||
|
||||
function triviaCategoriesForTopic(topic: string): string[] {
|
||||
const lower = topic.toLowerCase()
|
||||
const categories = new Set<string>()
|
||||
|
||||
if (/history|war|ancient|medieval|empire|civilization/.test(lower)) categories.add('history')
|
||||
if (/geography|country|capital|map|continent|river|mountain|city/.test(lower)) categories.add('geography')
|
||||
if (/science|biology|chemistry|physics|space|astronomy|medical|medicine|anatomy/.test(lower)) categories.add('science')
|
||||
if (/music|song|album|artist|band/.test(lower)) categories.add('music')
|
||||
if (/sport|football|soccer|basketball|baseball|tennis|golf/.test(lower)) categories.add('sport_and_leisure')
|
||||
if (/movie|film|television|tv|actor|cinema/.test(lower)) categories.add('film_and_tv')
|
||||
if (/\bart\b|literature|book|novel|author|poetry/.test(lower)) categories.add('arts_and_literature')
|
||||
if (/food|drink|cuisine|recipe|cooking/.test(lower)) categories.add('food_and_drink')
|
||||
if (/culture|society|language|religion|mythology|politics/.test(lower)) categories.add('society_and_culture')
|
||||
if (/general knowledge|trivia/.test(lower)) categories.add('general_knowledge')
|
||||
|
||||
return Array.from(categories)
|
||||
}
|
||||
|
||||
function isTechnicalQuizTopic(topic: string) {
|
||||
return /terraform|hcl|tfstate|provider block|resource block|terraform manifest|infrastructure as code|\biac\b|aws|ec2|s3|iam|vpc|rds|cloudfront|route 53|lambda|devops|docker|kubernetes|\bk8s\b|linux|ansible|azure|ci\/cd|pipeline/.test(topic.toLowerCase())
|
||||
}
|
||||
|
||||
function quizApiTagsForTopic(topic: string) {
|
||||
const lower = topic.toLowerCase()
|
||||
const tags = new Set<string>()
|
||||
|
||||
if (/terraform|hcl|tfstate|provider block|resource block|terraform manifest|infrastructure as code|\biac\b/.test(lower)) tags.add('terraform')
|
||||
else if (/typescript|\bts\b|\.d\.ts|type guard|interface/.test(lower)) tags.add('typescript')
|
||||
|
||||
if (!tags.has('terraform')) {
|
||||
if (/aws|ec2|s3|iam|vpc|rds|cloudfront|route 53|lambda/.test(lower)) tags.add('aws')
|
||||
if (/docker|container|image/.test(lower)) tags.add('docker')
|
||||
if (/kubernetes|\bk8s\b|pod|deployment|cluster/.test(lower)) tags.add('kubernetes')
|
||||
if (/linux|bash|shell|systemd|red hat|rhel/.test(lower)) tags.add('linux')
|
||||
if (/sql|mysql|postgres|database/.test(lower)) tags.add('sql')
|
||||
if (/ansible|playbook/.test(lower)) tags.add('ansible')
|
||||
if (/azure|devops pipeline|ci_cd|ci\/cd/.test(lower)) tags.add('azure')
|
||||
}
|
||||
|
||||
if (tags.size) return Array.from(tags).slice(0, 4).join(',')
|
||||
|
||||
return topic
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9+#.\s-]/g, ' ')
|
||||
.split(/[\s,/_-]+/)
|
||||
.filter((word) => word.length >= 3)
|
||||
.filter((word) => !['the', 'and', 'for', 'with', 'module', 'modules', 'lesson', 'course', 'quiz', 'part', 'step', 'introduction', 'create', 'test', 'build'].includes(word))
|
||||
.slice(0, 4)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
function focusKeywordsForTopic(topic: string) {
|
||||
const stopWords = new Set([
|
||||
'step', 'test', 'create', 'using', 'with', 'about', 'introduction', 'understand',
|
||||
'learn', 'build', 'building', 'manually', 'commands', 'command', 'execute',
|
||||
'executing', 'clean', 'course', 'lesson', 'video', 'quiz', 'what', 'which', 'this',
|
||||
])
|
||||
|
||||
return Array.from(new Set(
|
||||
topic
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length >= 4)
|
||||
.filter((word) => !stopWords.has(word))
|
||||
)).slice(0, 30)
|
||||
}
|
||||
|
||||
function scoreQuizApiItem(item: Record<string, unknown>, focusKeywords: string[]) {
|
||||
const searchable = [
|
||||
item.text,
|
||||
item.question,
|
||||
item.explanation,
|
||||
item.quizTitle,
|
||||
item.category,
|
||||
Array.isArray(item.tags) ? item.tags.join(' ') : '',
|
||||
].join(' ').toLowerCase()
|
||||
|
||||
return focusKeywords.filter((keyword) => searchable.includes(keyword)).length
|
||||
}
|
||||
|
||||
function selectRelevantQuizApiItems(items: Array<Record<string, unknown>>, topic: string) {
|
||||
const focusKeywords = focusKeywordsForTopic(topic)
|
||||
if (!focusKeywords.length) return items.slice(0, 10)
|
||||
|
||||
const ranked = items
|
||||
.map((item, index) => ({ item, index, score: scoreQuizApiItem(item, focusKeywords) }))
|
||||
.sort((a, b) => b.score - a.score || a.index - b.index)
|
||||
|
||||
const relevant = ranked.filter((entry) => entry.score > 0).map((entry) => entry.item)
|
||||
return (relevant.length >= 5 ? relevant : ranked.map((entry) => entry.item)).slice(0, 10)
|
||||
}
|
||||
|
||||
function mapTriviaApiResults(topic: string, results: Array<Record<string, unknown>>): QuizCache {
|
||||
const questions: QuizQuestion[] = results.map((result) => {
|
||||
const questionText = decodeHtmlEntities(triviaQuestionText(result.question))
|
||||
const correctAnswer = decodeHtmlEntities(String(result.correctAnswer || ''))
|
||||
const incorrectAnswers = Array.isArray(result.incorrectAnswers)
|
||||
? result.incorrectAnswers.map((answer) => decodeHtmlEntities(String(answer)))
|
||||
: []
|
||||
const options = shuffleArray([correctAnswer, ...incorrectAnswers].filter(Boolean))
|
||||
const answerIndex = Math.max(0, options.findIndex((option) => option === correctAnswer))
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
prompt: questionText,
|
||||
options,
|
||||
answerIndex,
|
||||
explanation: correctAnswer ? `Correct answer: ${correctAnswer}` : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const now = new Date().toISOString()
|
||||
return {
|
||||
version: 1,
|
||||
source: 'the-trivia-api',
|
||||
topic,
|
||||
title: buildQuizTitle(topic),
|
||||
description: buildQuizDescription(topic, 'the-trivia-api'),
|
||||
fetchedAt: now,
|
||||
updatedAt: now,
|
||||
questions,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTriviaApiQuiz(topic: string): Promise<QuizCache> {
|
||||
const categories = triviaCategoriesForTopic(topic)
|
||||
const categoryList = categories.length ? categories.join(',') : 'general_knowledge'
|
||||
|
||||
const url = new URL('https://the-trivia-api.com/v2/questions')
|
||||
url.searchParams.set('limit', '10')
|
||||
url.searchParams.set('categories', categoryList)
|
||||
url.searchParams.set('difficulty', 'medium')
|
||||
|
||||
let response = await fetch(url.toString())
|
||||
if (!response.ok || response.status === 404) {
|
||||
const fallback = new URL('https://the-trivia-api.com/v2/questions')
|
||||
fallback.searchParams.set('limit', '10')
|
||||
fallback.searchParams.set('categories', 'general_knowledge')
|
||||
fallback.searchParams.set('difficulty', 'medium')
|
||||
response = await fetch(fallback.toString())
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`The Trivia API request failed with status ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as Array<Record<string, unknown>>
|
||||
if (!data.length) {
|
||||
throw new Error('The Trivia API returned no quiz questions')
|
||||
}
|
||||
|
||||
return mapTriviaApiResults(topic, data)
|
||||
}
|
||||
|
||||
function naturalCompare(a: string, b: string) {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
|
||||
function cleanVideoTitle(fileName: string) {
|
||||
return normalizeQuizTopic(
|
||||
basename(fileName).replace(/\.[^.]+$/, '')
|
||||
.replace(/^\d+[\s._-]*/g, '')
|
||||
.replace(/^step[\s._-]*\d+[\s._-]*/i, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
)
|
||||
}
|
||||
|
||||
function topicDomainAnchorForPath(modulePath: string) {
|
||||
const courseFolder = normalizeQuizTopic(basename(dirname(modulePath)))
|
||||
const lower = courseFolder.toLowerCase()
|
||||
|
||||
if (/terraform|hashicorp/.test(lower)) return 'Terraform'
|
||||
if (/aws|amazon web services/.test(lower)) return 'AWS'
|
||||
if (/docker/.test(lower)) return 'Docker'
|
||||
if (/kubernetes|k8s/.test(lower)) return 'Kubernetes'
|
||||
if (/linux|red hat|rhel/.test(lower)) return 'Linux'
|
||||
if (/ansible/.test(lower)) return 'Ansible'
|
||||
if (/azure/.test(lower)) return 'Azure'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
async function buildQuizTopic(baseTopic: string, modulePath?: string): Promise<string> {
|
||||
if (modulePath) {
|
||||
try {
|
||||
const entries = await readdir(modulePath, { withFileTypes: true })
|
||||
const videoTitles = entries
|
||||
.filter((entry) => entry.isFile() && !entry.name.startsWith('.') && VIDEO_EXTENSIONS.has(`.${entry.name.split('.').pop()?.toLowerCase() || ''}`))
|
||||
.sort((a, b) => naturalCompare(a.name, b.name))
|
||||
.map((entry) => cleanVideoTitle(entry.name))
|
||||
.filter(Boolean)
|
||||
.filter((title) => !isIntroQuizTopic(title))
|
||||
.filter((part, index, all) => all.findIndex((item) => item.toLowerCase() === part.toLowerCase()) === index)
|
||||
.slice(0, 16)
|
||||
|
||||
const domainAnchor = topicDomainAnchorForPath(modulePath)
|
||||
const videoTopicParts = domainAnchor && !videoTitles.some((title) => title.toLowerCase().includes(domainAnchor.toLowerCase()))
|
||||
? [...videoTitles, domainAnchor]
|
||||
: videoTitles
|
||||
|
||||
if (videoTopicParts.length) {
|
||||
return videoTopicParts.join(' | ')
|
||||
}
|
||||
} catch {
|
||||
// Video-title enrichment must not block quiz generation.
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeQuizTopic(baseTopic)
|
||||
}
|
||||
|
||||
async function fetchQuizApiQuiz(topic: string, apiKey: string): Promise<QuizCache> {
|
||||
if (!apiKey) {
|
||||
throw new Error('QUIZAPI_KEY is not configured')
|
||||
}
|
||||
|
||||
const url = new URL('https://quizapi.io/api/v1/questions')
|
||||
url.searchParams.set('api_key', apiKey)
|
||||
url.searchParams.set('limit', '50')
|
||||
const tags = quizApiTagsForTopic(topic)
|
||||
if (tags) {
|
||||
url.searchParams.set('tags', tags)
|
||||
}
|
||||
if (tags.split(',').includes('terraform')) {
|
||||
url.searchParams.set('category', 'DevOps')
|
||||
}
|
||||
url.searchParams.set('random', 'true')
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
if (!response.ok) {
|
||||
throw new Error(`QuizAPI request failed with status ${response.status}`)
|
||||
}
|
||||
|
||||
const payload = await response.json() as unknown
|
||||
const data = Array.isArray(payload)
|
||||
? payload as Array<Record<string, unknown>>
|
||||
: payload && typeof payload === 'object' && Array.isArray((payload as { data?: unknown }).data)
|
||||
? (payload as { data: Array<Record<string, unknown>> }).data
|
||||
: []
|
||||
|
||||
if (!data.length) {
|
||||
throw new Error('QuizAPI returned no quiz questions')
|
||||
}
|
||||
|
||||
const selectedData = selectRelevantQuizApiItems(data, topic)
|
||||
const questions: QuizQuestion[] = selectedData.map((item) => {
|
||||
const rawAnswers = item.answers
|
||||
let optionEntries: Array<{ key: string; value: string; isCorrect: boolean }> = []
|
||||
|
||||
if (Array.isArray(rawAnswers)) {
|
||||
optionEntries = rawAnswers
|
||||
.filter((answer): answer is Record<string, unknown> => Boolean(answer) && typeof answer === 'object')
|
||||
.map((answer, index) => ({
|
||||
key: String(answer.id || index),
|
||||
value: decodeHtmlEntities(String(answer.text || '')),
|
||||
isCorrect: Boolean(answer.isCorrect),
|
||||
}))
|
||||
.filter((entry) => Boolean(entry.value))
|
||||
} else {
|
||||
const answers = rawAnswers && typeof rawAnswers === 'object' ? (rawAnswers as Record<string, string | null>) : {}
|
||||
const correctAnswers = item.correct_answers && typeof item.correct_answers === 'object'
|
||||
? (item.correct_answers as Record<string, string>)
|
||||
: {}
|
||||
optionEntries = Object.entries(answers)
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: decodeHtmlEntities(String(value)),
|
||||
isCorrect: String(correctAnswers[`${key}_correct`] || '').toLowerCase() === 'true',
|
||||
}))
|
||||
}
|
||||
|
||||
const options = optionEntries.map((entry) => entry.value)
|
||||
const answerIndex = optionEntries.findIndex((entry) => entry.isCorrect)
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
prompt: decodeHtmlEntities(String(item.text || item.question || 'Question')),
|
||||
options,
|
||||
answerIndex: Math.max(0, answerIndex),
|
||||
explanation: String(item.explanation || '') || undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const now = new Date().toISOString()
|
||||
return {
|
||||
version: 1,
|
||||
source: 'quizapi',
|
||||
topic,
|
||||
title: buildQuizTitle(topic),
|
||||
description: buildQuizDescription(topic, 'quizapi'),
|
||||
fetchedAt: now,
|
||||
updatedAt: now,
|
||||
questions,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchQuizBySource(topic: string, source: QuizSource, modulePath?: string): Promise<QuizCache> {
|
||||
const enrichedTopic = await buildQuizTopic(topic, modulePath)
|
||||
const technicalTopic = isTechnicalQuizTopic(enrichedTopic)
|
||||
|
||||
// Fetch QuizAPI key from database
|
||||
const quizApiKey = await getQuizApiKey()
|
||||
|
||||
if (source === 'quizapi' || technicalTopic) {
|
||||
if (!quizApiKey) {
|
||||
if (technicalTopic) {
|
||||
throw new Error('QUIZAPI_KEY is required for technical quiz topics. Configure it in Settings.')
|
||||
}
|
||||
console.warn('QuizAPI selected but QUIZAPI_KEY is not configured; falling back to The Trivia API.')
|
||||
return fetchTriviaApiQuiz(enrichedTopic)
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchQuizApiQuiz(enrichedTopic, quizApiKey)
|
||||
} catch (error) {
|
||||
if (technicalTopic) {
|
||||
throw error
|
||||
}
|
||||
console.warn('QuizAPI quiz generation failed; falling back to The Trivia API:', error)
|
||||
return fetchTriviaApiQuiz(enrichedTopic)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchTriviaApiQuiz(enrichedTopic)
|
||||
} catch (error) {
|
||||
if (!quizApiKey) {
|
||||
throw error
|
||||
}
|
||||
console.warn('The Trivia API quiz generation failed; falling back to QuizAPI:', error)
|
||||
return fetchQuizApiQuiz(enrichedTopic, quizApiKey)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadQuizCache(cachePath: string): Promise<QuizCache | null> {
|
||||
try {
|
||||
const raw = await readFile(cachePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as Partial<QuizCache> & { source?: unknown }
|
||||
if (!parsed || !Array.isArray(parsed.questions)) return null
|
||||
if (!isOnlineQuizSource(parsed.source)) return null
|
||||
return parsed as QuizCache
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeQuizCache(cachePath: string, quiz: QuizCache) {
|
||||
await mkdir(dirname(cachePath), { recursive: true })
|
||||
await writeFile(cachePath, JSON.stringify(quiz, null, 2), 'utf8')
|
||||
return cachePath
|
||||
}
|
||||
|
||||
export async function shouldSkipQuizGeneration(modulePath: string, topic: string) {
|
||||
if (isIntroQuizTopic(topic) || isIntroQuizTopic(basename(modulePath))) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(modulePath, { withFileTypes: true })
|
||||
const videoTitles = entries
|
||||
.filter((entry) => entry.isFile() && !entry.name.startsWith('.') && VIDEO_EXTENSIONS.has(`.${entry.name.split('.').pop()?.toLowerCase() || ''}`))
|
||||
.map((entry) => cleanVideoTitle(entry.name))
|
||||
.filter(Boolean)
|
||||
|
||||
return videoTitles.length > 0 && videoTitles.every((title) => isIntroQuizTopic(title))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureModuleQuizCache(options: {
|
||||
modulePath: string
|
||||
topic: string
|
||||
source: QuizSource
|
||||
force?: boolean
|
||||
}): Promise<{ quiz: QuizCache; cachePath: string; created: boolean } | null> {
|
||||
const cachePath = join(options.modulePath, QUIZ_CACHE_FILE)
|
||||
|
||||
if (await shouldSkipQuizGeneration(options.modulePath, options.topic)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existing = await loadQuizCache(cachePath)
|
||||
|
||||
if (existing && !options.force) {
|
||||
return { quiz: existing, cachePath, created: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const quiz = await fetchQuizBySource(options.topic, options.source, options.modulePath)
|
||||
await writeQuizCache(cachePath, quiz)
|
||||
return { quiz, cachePath, created: !existing }
|
||||
} catch (error) {
|
||||
if (existing) {
|
||||
return { quiz: existing, cachePath, created: false }
|
||||
}
|
||||
console.error('Failed to generate quiz cache:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getQuizCachePath(modulePath: string) {
|
||||
return join(modulePath, QUIZ_CACHE_FILE)
|
||||
}
|
||||
|
||||
export async function syncQuizLessonFromCache(options: {
|
||||
moduleId: string
|
||||
modulePath: string
|
||||
courseRoot: string
|
||||
topic?: string
|
||||
source?: QuizSource
|
||||
lessonOrder?: number
|
||||
autoFetch?: boolean
|
||||
force?: boolean
|
||||
}): Promise<{ quiz: QuizCache; cachePath: string; lesson: { id: string; title: string; slug: string; type: string; filePath: string }; created: boolean } | null> {
|
||||
const cachePath = getQuizCachePath(options.modulePath)
|
||||
let quiz = await loadQuizCache(cachePath)
|
||||
|
||||
if (!quiz && options.autoFetch) {
|
||||
const generated = await ensureModuleQuizCache({
|
||||
modulePath: options.modulePath,
|
||||
topic: options.topic || basename(options.modulePath),
|
||||
source: options.source || 'quizapi',
|
||||
force: options.force ?? false,
|
||||
})
|
||||
if (!generated) {
|
||||
return null
|
||||
}
|
||||
quiz = generated.quiz
|
||||
}
|
||||
|
||||
if (!quiz) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existingLesson = await prisma.lesson.findUnique({
|
||||
where: { moduleId_slug: { moduleId: options.moduleId, slug: 'quiz' } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const filePath = relative(options.courseRoot, cachePath).replace(/\\\\/g, '/')
|
||||
const order = options.lessonOrder ?? await prisma.lesson.count({ where: { moduleId: options.moduleId } })
|
||||
const lesson = await prisma.lesson.upsert({
|
||||
where: { moduleId_slug: { moduleId: options.moduleId, slug: 'quiz' } },
|
||||
update: {
|
||||
title: quiz.title || 'Quiz',
|
||||
order,
|
||||
filePath,
|
||||
fileName: 'quiz_cache.json',
|
||||
mimeType: 'application/json',
|
||||
type: 'QUIZ',
|
||||
duration: null,
|
||||
thumbnail: null,
|
||||
subtitlePath: null,
|
||||
},
|
||||
create: {
|
||||
title: quiz.title || 'Quiz',
|
||||
slug: 'quiz',
|
||||
order,
|
||||
filePath,
|
||||
fileName: 'quiz_cache.json',
|
||||
mimeType: 'application/json',
|
||||
type: 'QUIZ',
|
||||
duration: null,
|
||||
thumbnail: null,
|
||||
subtitlePath: null,
|
||||
moduleId: options.moduleId,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
quiz,
|
||||
cachePath,
|
||||
lesson,
|
||||
created: !existingLesson,
|
||||
}
|
||||
}
|
||||
Executable
+703
@@ -0,0 +1,703 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { join, relative, extname, basename, dirname } from 'path'
|
||||
import { getMimeType, getLessonType, slugify } from '@/lib/utils'
|
||||
import { getVideoDuration, generateVideoThumbnail, checkVideoTools } from '@/lib/video-utils'
|
||||
import { getCourseThumbnail } from '@/lib/thumbnail-index'
|
||||
import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server'
|
||||
import { ensureModuleQuizCache, syncQuizLessonFromCache, shouldSkipQuizGeneration } from '@/lib/quiz'
|
||||
import { applyCourseMetadata, isMetadataFileName, readCourseMetadataFile } from '@/lib/course-metadata'
|
||||
import { getLocalCourseThumbnailPath, ensurePublicThumbnail } from '@/lib/course-thumbnail-utils'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
// Natural sort: handles "1", "2", "10" correctly (not "1", "10", "2")
|
||||
function naturalSort(a: string, b: string): number {
|
||||
const re = /(\d+)|(\D+)/g
|
||||
const aParts = a.match(re) || []
|
||||
const bParts = b.match(re) || []
|
||||
|
||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||
const aPart = aParts[i] || ''
|
||||
const bPart = bParts[i] || ''
|
||||
const aNum = parseInt(aPart, 10)
|
||||
const bNum = parseInt(bPart, 10)
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
if (aNum !== bNum) return aNum - bNum
|
||||
} else {
|
||||
const cmp = aPart.localeCompare(bPart)
|
||||
if (cmp !== 0) return cmp
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mkv', '.webm', '.mov', '.avi', '.m4v', '.flv', '.wmv'])
|
||||
|
||||
async function directoryContainsVideo(dirPath: string, depth = 2): Promise<boolean> {
|
||||
if (depth <= 0) return false
|
||||
try {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && !entry.name.startsWith('.')) {
|
||||
if (VIDEO_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
||||
return true
|
||||
}
|
||||
} else if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
||||
if (await directoryContainsVideo(join(dirPath, entry.name), depth - 1)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore unreadable paths
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function getCoursesRoot(): Promise<string> {
|
||||
try {
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: 'coursesRoot' },
|
||||
})
|
||||
return setting?.value || './My_Courses'
|
||||
} catch {
|
||||
return './My_Courses'
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCoursesRootPath(): Promise<string> {
|
||||
return getCoursesRoot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and save course thumbnail locally
|
||||
*/
|
||||
async function downloadCourseThumbnail(courseName: string, courseSlug: string): Promise<string | null> {
|
||||
try {
|
||||
const thumbnailUrl = getCourseThumbnail(courseName, courseSlug)
|
||||
if (!thumbnailUrl) return null
|
||||
|
||||
const thumbDir = join(process.cwd(), 'public', 'thumbnails', 'courses')
|
||||
if (!existsSync(thumbDir)) {
|
||||
await mkdir(thumbDir, { recursive: true })
|
||||
}
|
||||
|
||||
const response = await fetch(thumbnailUrl)
|
||||
if (!response.ok) return null
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
|
||||
// Determine extension from Content-Type header (more reliable than URL)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
let primaryExt = '.png'
|
||||
if (contentType.includes('svg')) primaryExt = '.svg'
|
||||
else if (contentType.includes('jpeg') || contentType.includes('jpg')) primaryExt = '.jpg'
|
||||
else if (contentType.includes('png')) primaryExt = '.png'
|
||||
else if (contentType.includes('gif')) primaryExt = '.gif'
|
||||
else if (contentType.includes('webp')) primaryExt = '.webp'
|
||||
|
||||
// Fallback to URL-based detection
|
||||
if (primaryExt === '.png' && thumbnailUrl.includes('.svg')) primaryExt = '.svg'
|
||||
|
||||
// Save both .svg and .png versions to prevent 404s
|
||||
const extensionsToSave = primaryExt === '.svg' ? ['.svg', '.png'] : ['.png', '.svg']
|
||||
|
||||
let returnedPath = ''
|
||||
|
||||
for (const ext of extensionsToSave) {
|
||||
const filename = `${courseSlug}-thumb${ext}`
|
||||
const filepath = join(thumbDir, filename)
|
||||
|
||||
// For the secondary format, convert if needed
|
||||
if (ext !== primaryExt) {
|
||||
// We already have the buffer, just save with different extension
|
||||
await writeFile(filepath, Buffer.from(buffer))
|
||||
} else {
|
||||
await writeFile(filepath, Buffer.from(buffer))
|
||||
}
|
||||
|
||||
if (!returnedPath) {
|
||||
returnedPath = `/thumbnails/courses/${filename}`
|
||||
}
|
||||
|
||||
console.log(`Downloaded thumbnail for ${courseName} (${ext})`)
|
||||
}
|
||||
|
||||
return returnedPath
|
||||
} catch (error) {
|
||||
console.warn(`Failed to download thumbnail for ${courseName}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
coursesCreated: number
|
||||
coursesUpdated: number
|
||||
modulesCreated: number
|
||||
modulesUpdated: number
|
||||
lessonsCreated: number
|
||||
lessonsUpdated: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
interface CourseMetadata {
|
||||
title: string
|
||||
description?: string
|
||||
thumbnail?: string
|
||||
icon?: string
|
||||
tags: string[]
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
|
||||
const COURSE_METADATA_MAPPINGS: Record<string, CourseMetadata> = {
|
||||
'hashicorp': {
|
||||
title: 'HashiCorp Certified',
|
||||
description: 'Official HashiCorp certification courses for Terraform, Vault, Consul, and Nomad',
|
||||
tags: ['hashicorp', 'terraform', 'vault', 'consul', 'nomad', 'certification'],
|
||||
metadata: { vendor: 'HashiCorp', certifications: ['Terraform Associate', 'Vault Associate', 'Consul Associate'] },
|
||||
},
|
||||
'terraform': {
|
||||
title: 'Terraform',
|
||||
description: 'Infrastructure as Code with Terraform - from basics to advanced patterns',
|
||||
tags: ['iac', 'terraform', 'hashicorp', 'cloud', 'devops'],
|
||||
metadata: { provider: 'HashiCorp', registry: 'registry.terraform.io' },
|
||||
},
|
||||
'jenkins': {
|
||||
title: 'Jenkins CI/CD',
|
||||
description: 'Complete Jenkins pipeline automation - from basics to advanced declarative pipelines',
|
||||
tags: ['ci/cd', 'jenkins', 'automation', 'pipeline', 'devops'],
|
||||
metadata: { url: 'https://www.jenkins.io', plugins: ['Pipeline', 'Blue Ocean', 'GitHub Integration'] },
|
||||
},
|
||||
'kubernetes': {
|
||||
title: 'Kubernetes',
|
||||
description: 'Container orchestration with Kubernetes - fundamentals to advanced operations',
|
||||
tags: ['k8s', 'kubernetes', 'containers', 'orchestration', 'cloud-native'],
|
||||
metadata: { versions: ['1.28', '1.29', '1.30'], cni: ['Calico', 'Cilium', 'Flannel'] },
|
||||
},
|
||||
'docker': {
|
||||
title: 'Docker & Containers',
|
||||
description: 'Container fundamentals - Docker, Podman, Buildah, and container best practices',
|
||||
tags: ['docker', 'containers', 'podman', 'containerd', 'buildah'],
|
||||
metadata: { registry: 'Docker Hub', runtimes: ['containerd', 'cri-o', 'runc'] },
|
||||
},
|
||||
'ansible': {
|
||||
title: 'Ansible Automation',
|
||||
description: 'Infrastructure automation with Ansible - playbooks, roles, collections, and AWX',
|
||||
tags: ['ansible', 'automation', 'configuration-management', 'redhat'],
|
||||
metadata: { galaxy: 'galaxy.ansible.com', collections: ['community.general', 'community.docker', 'kubernetes.core'] },
|
||||
},
|
||||
'prometheus': {
|
||||
title: 'Prometheus & Grafana',
|
||||
description: 'Monitoring and observability with Prometheus, Grafana, Alertmanager, and Loki',
|
||||
tags: ['monitoring', 'prometheus', 'grafana', 'observability', 'alerting'],
|
||||
metadata: { stack: ['Prometheus', 'Grafana', 'Alertmanager', 'Loki', 'Tempo'] },
|
||||
},
|
||||
'github-actions': {
|
||||
title: 'GitHub Actions CI/CD',
|
||||
description: 'CI/CD pipelines with GitHub Actions - workflows, reusable actions, and self-hosted runners',
|
||||
tags: ['github', 'actions', 'ci/cd', 'workflows', 'automation'],
|
||||
metadata: { marketplace: 'GitHub Marketplace', runners: ['ubuntu-latest', 'windows-latest', 'macos-latest', 'self-hosted'] },
|
||||
},
|
||||
'linux': {
|
||||
title: 'Linux System Administration',
|
||||
description: 'Linux fundamentals - shell scripting, systemd, networking, security, and performance tuning',
|
||||
tags: ['linux', 'shell', 'bash', 'systemd', 'networking', 'security'],
|
||||
metadata: { distros: ['Ubuntu', 'Debian', 'RHEL', 'Fedora', 'Arch'], shells: ['bash', 'zsh', 'fish'] },
|
||||
},
|
||||
'python': {
|
||||
title: 'Python Programming',
|
||||
description: 'Python programming - from basics to advanced async, testing, and packaging',
|
||||
tags: ['python', 'programming', 'async', 'testing', 'packaging'],
|
||||
metadata: { versions: ['3.10', '3.11', '3.12'], frameworks: ['FastAPI', 'Django', 'Flask', 'Pydantic'] },
|
||||
},
|
||||
'go': {
|
||||
title: 'Go Programming',
|
||||
description: 'Go programming - concurrency, microservices, CLI tools, and performance',
|
||||
tags: ['go', 'golang', 'concurrency', 'microservices', 'cli'],
|
||||
metadata: { versions: ['1.21', '1.22'], tools: ['go modules', 'golangci-lint', 'delve'] },
|
||||
},
|
||||
'aws': {
|
||||
title: 'Amazon Web Services',
|
||||
description: 'AWS cloud services - compute, storage, networking, serverless, and security',
|
||||
tags: ['aws', 'cloud', 'serverless', 'infrastructure', 'devops'],
|
||||
metadata: { regions: ['us-east-1', 'us-west-2', 'eu-west-1'], certifications: ['Solutions Architect', 'Developer', 'SysOps'] },
|
||||
},
|
||||
'azure': {
|
||||
title: 'Microsoft Azure',
|
||||
description: 'Azure cloud platform - compute, storage, networking, DevOps, and AI services',
|
||||
tags: ['azure', 'cloud', 'microsoft', 'devops', 'ai'],
|
||||
metadata: { regions: ['East US', 'West Europe', 'Southeast Asia'], certifications: ['AZ-104', 'AZ-204', 'AZ-400'] },
|
||||
},
|
||||
'gcp': {
|
||||
title: 'Google Cloud Platform',
|
||||
description: 'Google Cloud - compute, storage, BigQuery, Kubernetes Engine, and AI/ML',
|
||||
tags: ['gcp', 'google-cloud', 'bigquery', 'kubernetes', 'ai'],
|
||||
metadata: { regions: ['us-central1', 'europe-west1', 'asia-northeast1'], certifications: ['Cloud Architect', 'Data Engineer', 'DevOps Engineer'] },
|
||||
},
|
||||
}
|
||||
|
||||
interface ScanOptions {
|
||||
skipVideoMetadata?: boolean
|
||||
maxConcurrency?: number
|
||||
maxLessonsPerCourse?: number
|
||||
maxModulesPerCourse?: number
|
||||
batchSize?: number
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
coursesCreated: number
|
||||
coursesUpdated: number
|
||||
modulesCreated: number
|
||||
modulesUpdated: number
|
||||
lessonsCreated: number
|
||||
lessonsUpdated: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
// Utility for concurrent task processing with limited concurrency
|
||||
async function parallelLimit<T>(
|
||||
items: T[],
|
||||
processor: (item: T) => Promise<void>,
|
||||
concurrency: number
|
||||
): Promise<void> {
|
||||
const queue = [...items]
|
||||
let running = 0
|
||||
|
||||
async function processNext() {
|
||||
if (queue.length === 0 && running === 0) return
|
||||
|
||||
const item = queue.shift()
|
||||
if (!item) return
|
||||
|
||||
running++
|
||||
try {
|
||||
await processor(item)
|
||||
} finally {
|
||||
running--
|
||||
processNext()
|
||||
}
|
||||
}
|
||||
|
||||
// Start initial workers
|
||||
while (running < concurrency && queue.length > 0) {
|
||||
processNext()
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
while (running > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
interface ScanOptions {
|
||||
skipVideoMetadata?: boolean
|
||||
maxConcurrency?: number
|
||||
maxLessonsPerCourse?: number
|
||||
maxModulesPerCourse?: number
|
||||
autoFetchQuizzes?: boolean
|
||||
quizApiSource?: 'the-trivia-api' | 'quizapi'
|
||||
}
|
||||
|
||||
async function scanCourses(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
const {
|
||||
skipVideoMetadata = true,
|
||||
maxConcurrency = 8,
|
||||
maxLessonsPerCourse = 500,
|
||||
maxModulesPerCourse = 50,
|
||||
batchSize = 50,
|
||||
autoFetchQuizzes = false,
|
||||
quizApiSource = 'quizapi',
|
||||
} = options
|
||||
|
||||
const result: ScanResult = {
|
||||
coursesCreated: 0,
|
||||
coursesUpdated: 0,
|
||||
modulesCreated: 0,
|
||||
modulesUpdated: 0,
|
||||
lessonsCreated: 0,
|
||||
lessonsUpdated: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
try {
|
||||
const coursesRoot = await getCoursesRoot()
|
||||
const rootStat = await stat(coursesRoot).catch(() => null)
|
||||
if (!rootStat || !rootStat.isDirectory()) {
|
||||
result.errors.push('Courses root directory not found: ' + coursesRoot)
|
||||
return result
|
||||
}
|
||||
|
||||
// Get all existing courses in one query to avoid N+1
|
||||
const existingCourses = await prisma.course.findMany({
|
||||
select: { id: true, slug: true, name: true, path: true, hidden: true, description: true, thumbnail: true, displayName: true }
|
||||
})
|
||||
const existingCourseMap = new Map(existingCourses.map(c => [c.slug, c]))
|
||||
|
||||
const hiddenCourseSlugs = new Set(
|
||||
existingCourses.filter(c => c.hidden).map(c => c.slug)
|
||||
)
|
||||
|
||||
const courseDirs = await readdir(coursesRoot, { withFileTypes: true })
|
||||
let courses = courseDirs.filter(d => d.isDirectory() && !d.name.startsWith('.') && !hiddenCourseSlugs.has(slugify(d.name)))
|
||||
|
||||
// Strict: ignore folders that don't contain any video files
|
||||
const strictCourses: typeof courses = []
|
||||
for (const courseDir of courses) {
|
||||
const coursePath = join(coursesRoot, courseDir.name)
|
||||
const hasVideo = await directoryContainsVideo(coursePath, 3)
|
||||
if (hasVideo) {
|
||||
strictCourses.push(courseDir)
|
||||
} else {
|
||||
result.errors.push(`Skipping empty/non-video course folder: ${courseDir.name}`)
|
||||
}
|
||||
}
|
||||
courses = strictCourses
|
||||
|
||||
// Batch upsert courses
|
||||
const courseUpserts = courses
|
||||
.map((courseDir) => ({
|
||||
where: { slug: slugify(courseDir.name) },
|
||||
update: {
|
||||
name: courseDir.name,
|
||||
path: join(coursesRoot, courseDir.name),
|
||||
},
|
||||
create: {
|
||||
name: courseDir.name,
|
||||
slug: slugify(courseDir.name),
|
||||
path: join(coursesRoot, courseDir.name),
|
||||
},
|
||||
}))
|
||||
|
||||
// Process in batches to avoid memory issues
|
||||
for (let i = 0; i < courseUpserts.length; i += batchSize) {
|
||||
const batch = courseUpserts.slice(i, i + batchSize)
|
||||
await prisma.$transaction(
|
||||
batch.map(upsert => prisma.course.upsert(upsert))
|
||||
)
|
||||
}
|
||||
|
||||
// Get updated courses
|
||||
const updatedCourses = await prisma.course.findMany({
|
||||
where: { slug: { in: courses.map(c => slugify(c.name)) } },
|
||||
select: { id: true, slug: true, name: true }
|
||||
})
|
||||
const courseMap = new Map(updatedCourses.map(c => [c.slug, c]))
|
||||
|
||||
// Now process modules and lessons with better batching
|
||||
for (const courseDir of courses) {
|
||||
if (result.errors.length > 50) break // Stop if too many errors
|
||||
|
||||
const courseSlug = slugify(courseDir.name)
|
||||
const course = courseMap.get(courseSlug)
|
||||
if (!course) continue
|
||||
|
||||
const existingCourse = existingCourseMap.get(courseSlug)
|
||||
if (!existingCourse) result.coursesCreated++
|
||||
else result.coursesUpdated++
|
||||
|
||||
const courseLowerName = courseDir.name.toLowerCase()
|
||||
let courseMetadata: CourseMetadata | null = null
|
||||
for (const [key, meta] of Object.entries(COURSE_METADATA_MAPPINGS)) {
|
||||
if (courseLowerName.includes(key)) {
|
||||
courseMetadata = meta
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const coursePath = join(coursesRoot, courseDir.name)
|
||||
const courseJsonMetadata = await readCourseMetadataFile(coursePath)
|
||||
if (courseJsonMetadata) {
|
||||
await applyCourseMetadata(course.id, courseJsonMetadata.metadata, `course-json:${courseJsonMetadata.fileName}`)
|
||||
}
|
||||
|
||||
await scanModules(course.id, coursePath, courseSlug, coursesRoot, result, courseMetadata, maxModulesPerCourse, maxLessonsPerCourse, autoFetchQuizzes, quizApiSource)
|
||||
|
||||
// Step 1: Local image scanner - check course folder root for cover/thumbnail images
|
||||
const localThumbPath = await getLocalCourseThumbnailPath(coursePath)
|
||||
let finalThumbnail: string | null = null
|
||||
if (localThumbPath) {
|
||||
finalThumbnail = await ensurePublicThumbnail(courseSlug, localThumbPath)
|
||||
}
|
||||
|
||||
if (courseMetadata) {
|
||||
const existingCourse = existingCourseMap.get(courseSlug)
|
||||
if (!finalThumbnail) {
|
||||
// Only download external thumbnail if no local cover found
|
||||
finalThumbnail = existingCourse?.thumbnail ? null : await downloadCourseThumbnail(courseDir.name, courseSlug)
|
||||
}
|
||||
await applyCourseMetadata(
|
||||
course.id,
|
||||
{
|
||||
displayName: courseMetadata.title,
|
||||
description: courseMetadata.description,
|
||||
thumbnail: finalThumbnail || undefined,
|
||||
tags: courseMetadata.tags,
|
||||
},
|
||||
'scanner-defaults'
|
||||
)
|
||||
} else if (finalThumbnail) {
|
||||
// Course has local cover but no metadata mapping - still save thumbnail
|
||||
await prisma.course.update({
|
||||
where: { id: course.id },
|
||||
data: { thumbnail: finalThumbnail },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push('Failed to scan courses root: ' + error)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function scanCoursesDirectory(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
return scanCourses({ skipVideoMetadata: true, maxConcurrency: 6, ...options }) // Fast scan with concurrency
|
||||
}
|
||||
|
||||
export async function scanCoursesFull(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
return scanCourses({ skipVideoMetadata: false, maxConcurrency: 3, ...options }) // Full scan with metadata, lower concurrency for resource limits
|
||||
}
|
||||
|
||||
async function scanModules(
|
||||
courseId: string,
|
||||
coursePath: string,
|
||||
courseSlug: string,
|
||||
coursesRoot: string,
|
||||
result: ScanResult,
|
||||
courseMetadata: CourseMetadata | null,
|
||||
maxModulesPerCourse: number,
|
||||
maxLessonsPerCourse: number,
|
||||
autoFetchQuizzes: boolean,
|
||||
quizApiSource: 'the-trivia-api' | 'quizapi'
|
||||
) {
|
||||
try {
|
||||
const entries = await readdir(coursePath, { withFileTypes: true })
|
||||
const moduleDirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).sort((a, b) => naturalSort(a.name, b.name))
|
||||
|
||||
// Get existing modules in one query
|
||||
const existingModules = await prisma.module.findMany({
|
||||
where: { courseId },
|
||||
select: { id: true, slug: true, name: true }
|
||||
})
|
||||
const existingModuleMap = new Map(existingModules.map(m => [m.slug, m]))
|
||||
|
||||
// Batch upsert modules
|
||||
const moduleUpserts = moduleDirs
|
||||
.slice(0, maxModulesPerCourse)
|
||||
.map((moduleDir, index) => ({
|
||||
where: { courseId_slug: { courseId, slug: slugify(moduleDir.name) } },
|
||||
update: { name: moduleDir.name, order: index },
|
||||
create: { name: moduleDir.name, slug: slugify(moduleDir.name), order: index, courseId },
|
||||
}))
|
||||
|
||||
if (moduleUpserts.length > 0) {
|
||||
await prisma.$transaction(
|
||||
moduleUpserts.map(upsert => prisma.module.upsert(upsert))
|
||||
)
|
||||
}
|
||||
|
||||
// Get updated modules
|
||||
const updatedModules = await prisma.module.findMany({
|
||||
where: { courseId, slug: { in: moduleUpserts.map(u => u.where.courseId_slug.slug) } },
|
||||
select: { id: true, slug: true, name: true }
|
||||
})
|
||||
const moduleMap = new Map(updatedModules.map(m => [m.slug, m]))
|
||||
|
||||
// Process modules and scan lessons
|
||||
for (const moduleDir of moduleDirs.slice(0, maxModulesPerCourse)) {
|
||||
const moduleSlug = slugify(moduleDir.name)
|
||||
const module = moduleMap.get(moduleSlug)
|
||||
if (!module) continue
|
||||
|
||||
const existingModule = existingModuleMap.get(moduleSlug)
|
||||
if (!existingModule) result.modulesCreated++
|
||||
else result.modulesUpdated++
|
||||
|
||||
// Scan lessons in this module
|
||||
await scanLessons(courseId, module.id, join(coursePath, moduleDir.name), coursesRoot, result, maxLessonsPerCourse, autoFetchQuizzes, quizApiSource)
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push('Failed to scan modules for course ' + courseSlug + ': ' + error)
|
||||
}
|
||||
}
|
||||
|
||||
async function scanLessons(
|
||||
courseId: string,
|
||||
moduleId: string,
|
||||
modulePath: string,
|
||||
coursesRoot: string,
|
||||
result: ScanResult,
|
||||
maxLessonsPerCourse: number,
|
||||
autoFetchQuizzes: boolean,
|
||||
quizApiSource: 'the-trivia-api' | 'quizapi'
|
||||
) {
|
||||
try {
|
||||
const moduleJsonMetadata = await readCourseMetadataFile(modulePath)
|
||||
if (moduleJsonMetadata) {
|
||||
await applyCourseMetadata(courseId, moduleJsonMetadata.metadata, `module-json:${basename(modulePath)}/${moduleJsonMetadata.fileName}`)
|
||||
}
|
||||
|
||||
// Track slugs within this module so same-name files do not overwrite each other.
|
||||
const moduleSeenSlugs = new Set<string>()
|
||||
|
||||
if (autoFetchQuizzes) {
|
||||
await ensureModuleQuizCache({
|
||||
modulePath,
|
||||
topic: basename(modulePath),
|
||||
source: quizApiSource,
|
||||
}).catch((error) => {
|
||||
result.errors.push('Failed to warm quiz cache for ' + modulePath + ': ' + error)
|
||||
})
|
||||
}
|
||||
|
||||
const entries = await readdir(modulePath, { withFileTypes: true })
|
||||
const files = entries
|
||||
.filter(e => e.isFile() && !e.name.startsWith('.'))
|
||||
.sort((a, b) => naturalSort(a.name, b.name))
|
||||
|
||||
// Build a map of subtitle files by their base name (for matching with videos)
|
||||
const subtitleMap = new Map<string, string>()
|
||||
for (const file of files) {
|
||||
const ext = extname(file.name).slice(1).toLowerCase()
|
||||
if (['srt', 'vtt'].includes(ext)) {
|
||||
const baseName = basename(file.name, extname(file.name))
|
||||
const relativePath = relative(coursesRoot, join(modulePath, file.name)).replace(/\\/g, '/')
|
||||
subtitleMap.set(baseName, relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing lessons in one query
|
||||
const existingLessons = await prisma.lesson.findMany({
|
||||
where: { moduleId },
|
||||
select: { id: true, slug: true }
|
||||
})
|
||||
const existingLessonMap = new Map(existingLessons.map(l => [l.slug, l]))
|
||||
|
||||
// Filter files to only include supported types
|
||||
const supportedFiles = files.filter(file => {
|
||||
if (file.name === 'quiz_cache.json' || isMetadataFileName(file.name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const mimeType = getMimeType(file.name)
|
||||
const lessonType = getLessonType(mimeType)
|
||||
if (lessonType === 'OTHER' && !['json', 'txt', 'srt', 'vtt'].includes(extname(file.name).slice(1))) {
|
||||
return false
|
||||
}
|
||||
// Skip subtitle files for lesson creation - they'll be attached to videos
|
||||
if (['srt', 'vtt'].includes(extname(file.name).slice(1).toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Prepare lesson upserts
|
||||
const lessonUpserts = supportedFiles
|
||||
.slice(0, maxLessonsPerCourse)
|
||||
.map((file, index) => {
|
||||
const relativePath = relative(coursesRoot, join(modulePath, file.name)).replace(/\\/g, '/')
|
||||
const mimeType = getMimeType(file.name)
|
||||
const lessonType = getLessonType(mimeType)
|
||||
const baseName = basename(file.name, extname(file.name))
|
||||
const baseSlug = slugify(baseName)
|
||||
|
||||
let lessonSlug = baseSlug
|
||||
if (moduleSeenSlugs.has(lessonSlug)) {
|
||||
const typedSlug = `${baseSlug}-${lessonType.toLowerCase()}`
|
||||
lessonSlug = typedSlug
|
||||
let suffix = 2
|
||||
while (moduleSeenSlugs.has(lessonSlug)) {
|
||||
lessonSlug = `${typedSlug}-${suffix}`
|
||||
suffix += 1
|
||||
}
|
||||
}
|
||||
moduleSeenSlugs.add(lessonSlug)
|
||||
|
||||
// Check if there's a matching subtitle file
|
||||
const subtitlePath = subtitleMap.get(baseName) || null
|
||||
|
||||
return {
|
||||
where: { moduleId_slug: { moduleId, slug: lessonSlug } },
|
||||
update: {
|
||||
title: baseName,
|
||||
order: index,
|
||||
filePath: relativePath,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
type: lessonType,
|
||||
duration: null,
|
||||
thumbnail: null,
|
||||
subtitlePath,
|
||||
},
|
||||
create: {
|
||||
title: baseName,
|
||||
slug: lessonSlug,
|
||||
order: index,
|
||||
filePath: relativePath,
|
||||
fileName: file.name,
|
||||
mimeType,
|
||||
type: lessonType,
|
||||
duration: null,
|
||||
thumbnail: null,
|
||||
subtitlePath,
|
||||
moduleId,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
if (lessonUpserts.length > 0) {
|
||||
await prisma.$transaction(
|
||||
lessonUpserts.map(upsert => prisma.lesson.upsert(upsert))
|
||||
)
|
||||
}
|
||||
|
||||
const moduleShouldSkipQuiz = await shouldSkipQuizGeneration(modulePath, basename(modulePath))
|
||||
|
||||
// Update counts for non-quiz lessons first
|
||||
const newLessons = lessonUpserts.filter(u => !existingLessonMap.has(u.where.moduleId_slug.slug))
|
||||
result.lessonsCreated += newLessons.length
|
||||
result.lessonsUpdated += lessonUpserts.length - newLessons.length
|
||||
|
||||
if (!moduleShouldSkipQuiz) {
|
||||
if (autoFetchQuizzes) {
|
||||
await ensureModuleQuizCache({
|
||||
modulePath,
|
||||
topic: basename(modulePath),
|
||||
source: quizApiSource,
|
||||
}).catch((error) => {
|
||||
result.errors.push('Failed to warm quiz cache for ' + modulePath + ': ' + error)
|
||||
})
|
||||
}
|
||||
|
||||
const quizSync = await syncQuizLessonFromCache({
|
||||
moduleId,
|
||||
modulePath,
|
||||
courseRoot: coursesRoot,
|
||||
topic: basename(modulePath),
|
||||
source: quizApiSource,
|
||||
lessonOrder: lessonUpserts.length,
|
||||
autoFetch: autoFetchQuizzes,
|
||||
})
|
||||
|
||||
if (quizSync) {
|
||||
if (quizSync.created) result.lessonsCreated += 1
|
||||
else result.lessonsUpdated += 1
|
||||
}
|
||||
} else {
|
||||
await prisma.lesson.deleteMany({ where: { moduleId, slug: 'quiz' } }).catch(() => {})
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push('Failed to scan lessons: ' + error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function getCourseStats(courseId: string) {
|
||||
const [modulesCount, lessonsCount] = await Promise.all([
|
||||
prisma.module.count({ where: { courseId } }),
|
||||
prisma.lesson.count({ where: { module: { courseId } } }),
|
||||
])
|
||||
return { modulesCount, lessonsCount }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Course Thumbnail Index - Server Only
|
||||
* Handles local filesystem thumbnail checks
|
||||
* Only import this in server components
|
||||
*/
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
/**
|
||||
* Get local thumbnail URL if it exists on filesystem
|
||||
* Server-side only - checks filesystem
|
||||
*/
|
||||
export async function getLocalThumbnailUrl(courseSlug: string): Promise<string | null> {
|
||||
// Check for .png first, then .svg
|
||||
for (const ext of ['.png', '.svg']) {
|
||||
const localPath = join(process.cwd(), 'public', 'thumbnails', 'courses', `${courseSlug}-thumb${ext}`)
|
||||
if (existsSync(localPath)) {
|
||||
return `/thumbnails/courses/${courseSlug}-thumb${ext}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Course Thumbnail Index - Client Safe
|
||||
* Maps course names to their external thumbnail URLs
|
||||
* Does NOT check local filesystem (server-side only)
|
||||
*
|
||||
* IMPORTANT: This file MUST NOT import 'fs' or 'path' modules
|
||||
* as it's used in client components. Server-side filesystem
|
||||
* operations should use lib/thumbnail-index-server.ts
|
||||
*/
|
||||
|
||||
const courseThumbnails: Record<string, string> = {
|
||||
// HashiCorp / Terraform
|
||||
'terraform': 'https://www.datocms-assets.com/2885/1623276269-terraform-logo.png',
|
||||
'hashicorp': 'https://www.datocms-assets.com/2885/1623276182-hashicorp-logo.png',
|
||||
'vault': 'https://www.datocms-assets.com/2885/1623276272-vault-logo.png',
|
||||
'consul': 'https://www.datocms-assets.com/2885/1623276274-consul-logo.png',
|
||||
'nomad': 'https://www.datocms-assets.com/2885/1623276276-nomad-logo.png',
|
||||
|
||||
// Jenkins
|
||||
'jenkins': 'https://www.jenkins.io/images/logos/jenkins/jenkins.svg',
|
||||
|
||||
// Kubernetes
|
||||
'kubernetes': 'https://raw.githubusercontent.com/kubernetes/kubernetes/master/logo/logo.png',
|
||||
'k8s': 'https://raw.githubusercontent.com/kubernetes/kubernetes/master/logo/logo.png',
|
||||
|
||||
// Docker
|
||||
'docker': 'https://www.docker.com/wp-content/uploads/2022/03/Moby-logo.png',
|
||||
'podman': 'https://podman.io/images/logo.svg',
|
||||
|
||||
// Ansible
|
||||
'ansible': 'https://ansible.com/img/ansible-logo-tm.png',
|
||||
|
||||
// Monitoring
|
||||
'prometheus': 'https://prometheus.io/assets/prometheus_logo-cb55bb5c346.png',
|
||||
'grafana': 'https://grafana.com/static/assets/img/blog/grafana_logo.png',
|
||||
|
||||
// CI/CD
|
||||
'github-actions': 'https://github.githubassets.com/images/modules/site/github-actions-icon.png',
|
||||
'gitlab': 'https://about.gitlab.com/images/press/logo.svg',
|
||||
|
||||
// Linux
|
||||
'linux': 'https://upload.wikimedia.org/wikipedia/commons/3/35/Tux.svg',
|
||||
'ubuntu': 'https://assets.ubuntu.com/v1/29985a98-ubuntu-logo32.png',
|
||||
|
||||
// Python
|
||||
'python': 'https://www.python.org/static/community_logos/python-logo-generic.svg',
|
||||
|
||||
// Go
|
||||
'go': 'https://go.dev/images/gophers/gopher.svg',
|
||||
'golang': 'https://go.dev/images/gophers/gopher.svg',
|
||||
|
||||
// Cloud
|
||||
'aws': 'https://a0.awsstatic.com/libra-css/images/logos/aws_logo_smile_1200x630.png',
|
||||
'azure': 'https://azure.microsoft.com/svghandler/azure-logo/',
|
||||
'gcp': 'https://cloud.google.com/images/social-icon-google-cloud-1200-630.png',
|
||||
|
||||
// Programming
|
||||
'javascript': 'https://raw.githubusercontent.com/github/explore/main/topics/javascript/javascript.png',
|
||||
'typescript': 'https://raw.githubusercontent.com/github/explore/main/topics/typescript/typescript.png',
|
||||
'react': 'https://raw.githubusercontent.com/github/explore/main/topics/react/react.png',
|
||||
'vue': 'https://raw.githubusercontent.com/github/explore/main/topics/vue/vue.png',
|
||||
'nodejs': 'https://raw.githubusercontent.com/github/explore/main/topics/nodejs/nodejs.png',
|
||||
|
||||
// DevOps tools
|
||||
'packer': 'https://www.datocms-assets.com/2885/1623276282-packer-logo.png',
|
||||
'vagrant': 'https://www.datocms-assets.com/2885/1623276284-vagrant-logo.png',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get external thumbnail URL from index
|
||||
*/
|
||||
export function getCourseThumbnail(courseName: string, slug?: string): string | null {
|
||||
const name = courseName.toLowerCase()
|
||||
const courseSlug = (slug || '').toLowerCase()
|
||||
|
||||
for (const [key, url] of Object.entries(courseThumbnails)) {
|
||||
if (name.includes(key) || courseSlug.includes(key)) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Client-side version that uses database-provided thumbnail
|
||||
export function getCourseThumbnailClient(course: { thumbnail?: string | null; name: string; slug?: string }): string | null {
|
||||
// First use database-stored thumbnail
|
||||
if (course.thumbnail) {
|
||||
return course.thumbnail
|
||||
}
|
||||
|
||||
// Fallback to external URLs from index
|
||||
return getCourseThumbnail(course.name, course.slug)
|
||||
}
|
||||
|
||||
export function getAvailableThumbnails(): string[] {
|
||||
return Object.values(courseThumbnails)
|
||||
}
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return hours + 'h ' + minutes + 'm'
|
||||
}
|
||||
return minutes + 'm ' + secs + 's'
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0')
|
||||
}
|
||||
return minutes + ':' + String(secs).padStart(2, '0')
|
||||
}
|
||||
|
||||
export function slugify(text: string): string {
|
||||
const result = text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return result || 'lesson'
|
||||
}
|
||||
|
||||
export function getMimeType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
mp4: 'video/mp4',
|
||||
mkv: 'video/x-matroska',
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
avi: 'video/x-msvideo',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
m4a: 'audio/mp4',
|
||||
pdf: 'application/pdf',
|
||||
md: 'text/markdown',
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
json: 'application/json',
|
||||
txt: 'text/plain',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
srt: 'text/plain',
|
||||
vtt: 'text/vtt',
|
||||
}
|
||||
return mimeTypes[ext || ''] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
export function getLessonType(mimeType: string): string {
|
||||
if (mimeType.startsWith('video/')) return 'VIDEO'
|
||||
if (mimeType.startsWith('audio/')) return 'AUDIO'
|
||||
if (mimeType === 'application/pdf') return 'PDF'
|
||||
if (mimeType === 'text/markdown') return 'MARKDOWN'
|
||||
if (mimeType.startsWith('text/html')) return 'HTML'
|
||||
if (mimeType === 'application/json') return 'JSON'
|
||||
if (mimeType === 'text/plain') return 'TEXT'
|
||||
if (mimeType === 'text/vtt') return 'VTT'
|
||||
if (mimeType.startsWith('image/')) return 'IMAGE'
|
||||
return 'OTHER'
|
||||
}
|
||||
|
||||
export function isVideoType(type: string): boolean {
|
||||
return type === 'VIDEO'
|
||||
}
|
||||
|
||||
export function isDocumentType(type: string): boolean {
|
||||
return ['PDF', 'MARKDOWN', 'HTML'].includes(type)
|
||||
}
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
import { execSync } from 'child_process'
|
||||
import { join, dirname, basename, extname } from 'path'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
|
||||
/**
|
||||
* Get video duration in seconds using ffprobe
|
||||
*/
|
||||
export function getVideoDuration(filePath: string): number | null {
|
||||
try {
|
||||
const output = execSync(
|
||||
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
|
||||
{ encoding: 'utf-8', timeout: 30000 }
|
||||
)
|
||||
const duration = parseFloat(output.trim())
|
||||
return isNaN(duration) ? null : Math.round(duration)
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get duration for ${filePath}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnail from video at specified time (default 10% or 5 seconds)
|
||||
*/
|
||||
export function generateVideoThumbnail(
|
||||
videoPath: string,
|
||||
thumbnailDir: string,
|
||||
timePercent: number = 0.1
|
||||
): string | null {
|
||||
try {
|
||||
// Get video duration first to calculate timestamp
|
||||
const duration = getVideoDuration(videoPath)
|
||||
if (!duration) return null
|
||||
|
||||
const timestamp = Math.min(duration * timePercent, 5) // Max 5 seconds or 10%
|
||||
const timeStr = formatTimeForFfmpeg(timestamp)
|
||||
|
||||
// Create thumbnail directory if it doesn't exist
|
||||
if (!existsSync(thumbnailDir)) {
|
||||
mkdirSync(thumbnailDir, { recursive: true })
|
||||
}
|
||||
|
||||
const videoName = basename(videoPath, extname(videoPath))
|
||||
const thumbnailName = `${videoName}-thumb.jpg`
|
||||
const thumbnailPath = join(thumbnailDir, thumbnailName)
|
||||
|
||||
// Generate thumbnail using ffmpeg
|
||||
execSync(
|
||||
`ffmpeg -y -ss ${timeStr} -i "${videoPath}" -vframes 1 -q:v 2 -vf "scale=320:-1" "${thumbnailPath}"`,
|
||||
{ stdio: 'ignore', timeout: 60000 }
|
||||
)
|
||||
|
||||
if (existsSync(thumbnailPath)) {
|
||||
return thumbnailName
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${videoPath}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds as HH:MM:SS for ffmpeg -ss parameter
|
||||
*/
|
||||
function formatTimeForFfmpeg(seconds: number): string {
|
||||
const hrs = Math.floor(seconds / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
const ms = Math.round((seconds % 1) * 1000)
|
||||
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ffmpeg/ffprobe are available
|
||||
*/
|
||||
export function checkVideoTools(): boolean {
|
||||
try {
|
||||
execSync('ffprobe -version', { stdio: 'ignore' })
|
||||
execSync('ffmpeg -version', { stdio: 'ignore' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
export function saveProgressFn(lessonId: string, courseId: string, moduleId: string, position: number) {
|
||||
return fetch('/api/progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
lessonId,
|
||||
courseId,
|
||||
moduleId,
|
||||
position,
|
||||
completed: false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function markCompleteFn(lessonId: string, courseId: string, moduleId: string, position: number) {
|
||||
return fetch('/api/progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
lessonId,
|
||||
courseId,
|
||||
moduleId,
|
||||
position,
|
||||
completed: true
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user