mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-07-26 11:58:44 +00:00
209 lines
5.5 KiB
TypeScript
209 lines
5.5 KiB
TypeScript
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 }
|
|
}
|