initial commit

This commit is contained in:
Nice Try
2026-06-26 02:51:08 +00:00
commit ad5810fba9
133 changed files with 28275 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
export interface CourseTitleLike {
name: string
displayName?: string | null
}
export function getCourseDisplayName(course: CourseTitleLike): string {
return course.displayName?.trim() || course.name
}
+208
View File
@@ -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 }
}
+87
View File
@@ -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'
}
+19
View File
@@ -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]
}
+172
View File
@@ -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
View File
@@ -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
+48
View File
@@ -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
View File
@@ -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(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/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
View File
@@ -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 }
}
+22
View File
@@ -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
}
+98
View File
@@ -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
View File
@@ -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)
}
+85
View File
@@ -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
}
}
+27
View File
@@ -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
})
})
}