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, 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 { 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() 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() 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, ) } }