LernApp/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PParser.kt
Oliver Wallisch bc166cae62
Some checks failed
CI Pipeline / lint-and-test (pull_request) Failing after 1m19s
feat: integrated h5p, new endpoints and cli tool to generate new tasks
2026-09-14 23:29:37 +02:00

271 lines
9.2 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&times;", "×")
.replace("&bdquo;", "„")
.replace("&ldquo;", "“")
.replace("&auml;", "ä")
.replace("&Auml;", "Ä")
.replace("&ouml;", "ö")
.replace("&Ouml;", "Ö")
.replace("&uuml;", "ü")
.replace("&Uuml;", "Ü")
.replace("&szlig;", "ß")
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,
)
}
}