271 lines
9.2 KiB
Kotlin
271 lines
9.2 KiB
Kotlin
package com.oliver.kidio.backend.ingestion
|
||
|
||
import kotlinx.serialization.json.*
|
||
import java.io.InputStream
|
||
import java.util.zip.ZipInputStream
|
||
|
||
data class ParsedH5PTask(
|
||
val id: String,
|
||
val title: String,
|
||
val library: String,
|
||
val question: String,
|
||
val options: List<String>,
|
||
val correctAnswer: String,
|
||
val license: String = "CC BY-SA 4.0",
|
||
val sourceUrl: String? = null,
|
||
)
|
||
|
||
object H5PParser {
|
||
private val json =
|
||
Json {
|
||
ignoreUnknownKeys = true
|
||
isLenient = true
|
||
}
|
||
|
||
/**
|
||
* Strips HTML tags and decodes common HTML entities to provide clean text.
|
||
*/
|
||
fun stripHtml(html: String): String {
|
||
var text = html.replace(Regex("(?i)<(br|p|div|li|tr|h[1-6])[^>]*>"), " ")
|
||
text = text.replace(Regex("<[^>]*>"), "")
|
||
text =
|
||
text
|
||
.replace(" ", " ")
|
||
.replace("&", "&")
|
||
.replace(""", "\"")
|
||
.replace("'", "'")
|
||
.replace("'", "'")
|
||
.replace("<", "<")
|
||
.replace(">", ">")
|
||
.replace("×", "×")
|
||
.replace("„", "„")
|
||
.replace("“", "“")
|
||
.replace("ä", "ä")
|
||
.replace("Ä", "Ä")
|
||
.replace("ö", "ö")
|
||
.replace("Ö", "Ö")
|
||
.replace("ü", "ü")
|
||
.replace("Ü", "Ü")
|
||
.replace("ß", "ß")
|
||
text = text.replace(Regex("\\s+([?.!,:;])"), "$1")
|
||
return text.replace(Regex("\\s+"), " ").trim()
|
||
}
|
||
|
||
/**
|
||
* Parses an .h5p ZIP archive stream.
|
||
*/
|
||
fun parseH5PZip(
|
||
inputStream: InputStream,
|
||
taskId: String,
|
||
sourceUrl: String? = null,
|
||
): ParsedH5PTask? {
|
||
val zip = ZipInputStream(inputStream)
|
||
var h5pJsonString: String? = null
|
||
var contentJsonString: String? = null
|
||
|
||
var entry = zip.nextEntry
|
||
while (entry != null) {
|
||
val name = entry.name
|
||
if (name == "h5p.json" || name.endsWith("/h5p.json")) {
|
||
h5pJsonString = zip.readBytes().toString(Charsets.UTF_8)
|
||
} else if (name == "content/content.json" || name == "content.json" || name.endsWith("/content.json")) {
|
||
contentJsonString = zip.readBytes().toString(Charsets.UTF_8)
|
||
}
|
||
zip.closeEntry()
|
||
entry = zip.nextEntry
|
||
}
|
||
|
||
if (contentJsonString == null) {
|
||
return null
|
||
}
|
||
|
||
var library = "H5P.MultiChoice"
|
||
var title = "H5P Task"
|
||
var license = "CC BY-SA 4.0"
|
||
|
||
if (h5pJsonString != null) {
|
||
try {
|
||
val h5pTree = json.parseToJsonElement(h5pJsonString).jsonObject
|
||
library = h5pTree["mainLibrary"]?.jsonPrimitive?.contentOrNull ?: library
|
||
title = h5pTree["title"]?.jsonPrimitive?.contentOrNull ?: title
|
||
license = h5pTree["license"]?.jsonPrimitive?.contentOrNull ?: license
|
||
} catch (_: Exception) {
|
||
}
|
||
}
|
||
|
||
return parseContentJson(
|
||
contentJson = contentJsonString,
|
||
library = library,
|
||
taskId = taskId,
|
||
title = title,
|
||
license = license,
|
||
sourceUrl = sourceUrl,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Parses content.json representation for supported H5P libraries (MultiChoice, TrueFalse).
|
||
*/
|
||
fun parseContentJson(
|
||
contentJson: String,
|
||
library: String = "H5P.MultiChoice",
|
||
taskId: String = "task-1",
|
||
title: String = "H5P Task",
|
||
license: String = "CC BY-SA 4.0",
|
||
sourceUrl: String? = null,
|
||
): ParsedH5PTask? {
|
||
val element =
|
||
try {
|
||
json.parseToJsonElement(contentJson).jsonObject
|
||
} catch (e: Exception) {
|
||
return null
|
||
}
|
||
|
||
if (library.contains("TrueFalse", ignoreCase = true) || (element.containsKey("correct") && !element.containsKey("answers"))) {
|
||
return parseTrueFalse(element, taskId, title, library, license, sourceUrl)
|
||
}
|
||
|
||
return parseMultiChoice(element, taskId, title, library, license, sourceUrl)
|
||
}
|
||
|
||
/**
|
||
* Parses one or multiple tasks from content.json (supports MultiChoice, TrueFalse, and QuestionSet).
|
||
*/
|
||
fun parseAllTasks(
|
||
contentJson: String,
|
||
library: String = "H5P.MultiChoice",
|
||
taskId: String = "task-1",
|
||
title: String = "H5P Task",
|
||
license: String = "CC BY-SA 4.0",
|
||
sourceUrl: String? = null,
|
||
): List<ParsedH5PTask> {
|
||
val element =
|
||
try {
|
||
json.parseToJsonElement(contentJson).jsonObject
|
||
} catch (e: Exception) {
|
||
return emptyList()
|
||
}
|
||
|
||
if (library.contains("QuestionSet", ignoreCase = true) && element.containsKey("questions")) {
|
||
val questionsArray = element["questions"]?.jsonArray ?: return emptyList()
|
||
val results = mutableListOf<ParsedH5PTask>()
|
||
|
||
for ((idx, qElement) in questionsArray.withIndex()) {
|
||
val qObj = qElement.jsonObject
|
||
val subLib = qObj["library"]?.jsonPrimitive?.contentOrNull ?: "H5P.MultiChoice"
|
||
val subParams = qObj["params"]?.jsonObject ?: continue
|
||
val subId = "$taskId-q${idx + 1}"
|
||
|
||
val isTrueFalse =
|
||
subLib.contains("TrueFalse", ignoreCase = true) ||
|
||
(subParams.containsKey("correct") && !subParams.containsKey("answers"))
|
||
val isMultiChoice =
|
||
subLib.contains("MultiChoice", ignoreCase = true) || subParams.containsKey("answers")
|
||
|
||
val parsedSub =
|
||
if (isTrueFalse) {
|
||
parseTrueFalse(subParams, subId, "$title #${idx + 1}", subLib, license, sourceUrl)
|
||
} else if (isMultiChoice) {
|
||
parseMultiChoice(subParams, subId, "$title #${idx + 1}", subLib, license, sourceUrl)
|
||
} else {
|
||
null
|
||
}
|
||
|
||
if (parsedSub != null) {
|
||
results.add(parsedSub)
|
||
}
|
||
}
|
||
return results
|
||
}
|
||
|
||
val single = parseContentJson(contentJson, library, taskId, title, license, sourceUrl)
|
||
return if (single != null) listOf(single) else emptyList()
|
||
}
|
||
|
||
private fun parseMultiChoice(
|
||
element: JsonObject,
|
||
taskId: String,
|
||
title: String,
|
||
library: String,
|
||
license: String,
|
||
sourceUrl: String?,
|
||
): ParsedH5PTask? {
|
||
val questionRaw =
|
||
element["question"]?.jsonPrimitive?.contentOrNull
|
||
?: element["text"]?.jsonPrimitive?.contentOrNull
|
||
?: return null
|
||
val questionClean = stripHtml(questionRaw)
|
||
if (questionClean.isBlank()) return null
|
||
|
||
val answersArray = element["answers"]?.jsonArray ?: return null
|
||
val options = mutableListOf<String>()
|
||
var correctAnswer: String? = null
|
||
|
||
for (item in answersArray) {
|
||
val itemObj = item.jsonObject
|
||
val textRaw = itemObj["text"]?.jsonPrimitive?.contentOrNull ?: continue
|
||
val textClean = stripHtml(textRaw)
|
||
if (textClean.isNotBlank()) {
|
||
options.add(textClean)
|
||
val isCorrect = itemObj["correct"]?.jsonPrimitive?.booleanOrNull ?: false
|
||
if (isCorrect && correctAnswer == null) {
|
||
correctAnswer = textClean
|
||
}
|
||
}
|
||
}
|
||
|
||
if (options.isEmpty() || correctAnswer == null) {
|
||
return null
|
||
}
|
||
|
||
return ParsedH5PTask(
|
||
id = taskId,
|
||
title = title,
|
||
library = library,
|
||
question = questionClean,
|
||
options = options,
|
||
correctAnswer = correctAnswer,
|
||
license = license,
|
||
sourceUrl = sourceUrl,
|
||
)
|
||
}
|
||
|
||
private fun parseTrueFalse(
|
||
element: JsonObject,
|
||
taskId: String,
|
||
title: String,
|
||
library: String,
|
||
license: String,
|
||
sourceUrl: String?,
|
||
): ParsedH5PTask? {
|
||
val questionRaw =
|
||
element["question"]?.jsonPrimitive?.contentOrNull
|
||
?: element["text"]?.jsonPrimitive?.contentOrNull
|
||
?: return null
|
||
val questionClean = stripHtml(questionRaw)
|
||
if (questionClean.isBlank()) return null
|
||
|
||
val correctPrimitive = element["correct"]?.jsonPrimitive
|
||
val isTrueCorrect =
|
||
when {
|
||
correctPrimitive?.booleanOrNull != null -> correctPrimitive.boolean
|
||
correctPrimitive?.contentOrNull != null -> correctPrimitive.content.equals("true", ignoreCase = true)
|
||
else -> true
|
||
}
|
||
|
||
val options = listOf("Wahr", "Falsch")
|
||
val correctAnswer = if (isTrueCorrect) "Wahr" else "Falsch"
|
||
|
||
return ParsedH5PTask(
|
||
id = taskId,
|
||
title = title,
|
||
library = library,
|
||
question = questionClean,
|
||
options = options,
|
||
correctAnswer = correctAnswer,
|
||
license = license,
|
||
sourceUrl = sourceUrl,
|
||
)
|
||
}
|
||
}
|