feat/adding-h5p-tasks #10

Merged
DrunkenPinguin merged 6 commits from feat/adding-h5p-tasks into main 2026-09-15 13:00:22 +00:00
4 changed files with 137 additions and 32 deletions
Showing only changes of commit b082df962f - Show all commits

View file

@ -18,12 +18,17 @@ import org.mindrot.jbcrypt.BCrypt
interface KidioRepository {
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
suspend fun getUserById(userId: String): User?
suspend fun getTasks(
subject: String? = null,
age: Int? = null,
limit: Int = 10,
): List<Task>
// Mit suspend markiert, damit es zur DatabaseFactory.dbQuery Architektur passt
suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set<String>
suspend fun upsertTask(task: ImportedTask): Boolean
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
@ -74,8 +79,35 @@ class ExposedKidioRepository : KidioRepository {
}
}
override suspend fun getUserById(userId: String): User? =
DatabaseFactory.dbQuery {
UserTable.selectAll()
.where { UserTable.id eq userId }
.map {
User(
id = it[UserTable.id],
email = it[UserTable.email],
passwordHash = it[UserTable.passwordHash],
familyName = it[UserTable.familyName],
childAge = it[UserTable.childAge],
parentPin = it[UserTable.parentPin],
)
}
.singleOrNull()
}
override suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
// NEU: Implementierung für Exposed (PostgreSQL)
override suspend fun getExistingTaskIds(externalSource: String): Set<String> =
DatabaseFactory.dbQuery {
TasksTable
.select(TasksTable.id) // Wir brauchen nur die ID-Spalte
.where { TasksTable.externalSource eq externalSource }
.map { it[TasksTable.id] }
.toSet()
}
override suspend fun upsertTask(task: ImportedTask): Boolean =
DatabaseFactory.dbQuery {
try {
@ -237,25 +269,31 @@ class InMemoryKidioRepository : KidioRepository {
mutableListOf(
ImportedTask(
DrunkenPinguin marked this conversation as resolved
Review

Eigene Aufgaben rausnehmen oder in eine JSON Datei packen

Eigene Aufgaben rausnehmen oder in eine JSON Datei packen
id = "t1",
externalSource = "manual",
subject = "Mathematik",
minAge = 6,
maxAge = 10,
taskType = "MULTIPLE_CHOICE",
question = "Was ist 12 + 15?",
options = listOf("25", "27", "30", "22"),
correctAnswer = "27",
rewardMinutes = 10,
license = "CC BY-SA 4.0",
sourceUrl = "local",
),
ImportedTask(
id = "t2",
externalSource = "manual",
subject = "Mathematik",
minAge = 7,
maxAge = 12,
taskType = "MULTIPLE_CHOICE",
question = "Was ist 8 x 7?",
options = listOf("54", "56", "64", "48"),
correctAnswer = "56",
rewardMinutes = 15,
license = "CC BY-SA 4.0",
sourceUrl = "local",
),
)
private val screentimes =
@ -290,6 +328,18 @@ class InMemoryKidioRepository : KidioRepository {
}
}
override suspend fun getUserById(userId: String): User? {
return users.find { it.id == userId }
}
// NEU: Implementierung für InMemory (Mock-Daten)
override suspend fun getExistingTaskIds(externalSource: String): Set<String> {
return tasks
.filter { it.externalSource == externalSource }
.map { it.id }
.toSet()
}
override suspend fun upsertTask(task: ImportedTask): Boolean {
tasks.removeIf { it.id == task.id }
tasks.add(task)

View file

@ -21,8 +21,18 @@ object H5PIngestionCli {
limit: Int = 50,
): Int =
runBlocking {
// Bereits vorhandene Task-IDs aus der Datenbank auslesen
val existingIds = repository.getExistingTaskIds("zum.de")
println("Bereits in DB vorhandene ZUM-Aufgaben: ${existingIds.size}")
var importedCount = 0
val items = client.fetchLiveOerItems(subject = subject, limit = limit)
// existingIds an fetchLiveOerItems übergeben, um Duplikate vor dem Download zu filtern
val items =
client.fetchLiveOerItems(
subject = subject,
limit = limit,
existingIds = existingIds,
)
for (item in items) {
var parsedList = emptyList<ParsedH5PTask>()

View file

@ -97,12 +97,14 @@ class ZumApiClient(
/**
* Fetches live OER items from apps.zum.de via search and Drupal settings extraction.
* Skips items whose IDs are already present in existingIds.
* Falls back to curated items if remote catalog is unreachable or returns 0 matching interactive items.
*/
fun fetchLiveOerItems(
subject: String? = null,
limit: Int = 50,
maxPages: Int = 5,
maxPages: Int = 15,
existingIds: Set<String> = emptySet(),
): List<ZumOerItem> {
val results = mutableListOf<ZumOerItem>()
val seenAppIds = mutableSetOf<String>()
@ -126,17 +128,21 @@ class ZumApiClient(
continue
}
// Filtert bereits verarbeitete oder in DB vorhandene Apps heraus
val appIds =
Regex("""href=["']/apps/(\d+)["']""")
.findAll(html)
.map { it.groupValues[1] }
.distinct()
.filter { it !in seenAppIds }
.filter { appId ->
val fullId = "zum-$appId"
!seenAppIds.contains(appId) && !existingIds.contains(fullId) && !existingIds.contains(appId)
}
.toList()
if (appIds.isEmpty()) {
println("[ZUM-Apps Live] Keine weiteren Apps auf Seite $page gefunden.")
break
println("[ZUM-Apps Live] Seite $page enthält keine neuen Aufgaben. Blättere weiter...")
continue
}
for (appId in appIds) {
@ -146,17 +152,21 @@ class ZumApiClient(
val item = fetchAppDetails(appId, subject)
if (item != null) {
results.add(item)
println("[ZUM-Apps Live] App #$appId erfasst: \"${item.title}\" (${item.library ?: "H5P"}) [Fach: ${item.subject}]")
val lib = item.library ?: "H5P"
val gr = item.grade ?: "k.A."
println(
"[ZUM-Apps Live] Neue App #$appId: \"${item.title}\" ($lib) [${item.subject}, Kl. $gr]",
)
}
}
}
if (results.isEmpty()) {
println("[ZUM-Apps Live] Keine Live-Inhalte gefunden oder Seite nicht erreichbar. Nutze kuratierte OER-Fallback-Daten.")
println("[ZUM-Apps Live] Keine neuen Live-Inhalte gefunden oder Seite nicht erreichbar. Nutze kuratierte OER-Fallback-Daten.")
return getCuratedOerItems(subject = subject, limit = limit)
}
println("[ZUM-Apps Live] ${results.size} interaktive Aufgaben erfolgreich von apps.zum.de geladen.")
println("[ZUM-Apps Live] ${results.size} neue interaktive Aufgaben erfolgreich von apps.zum.de geladen.")
return results
}
@ -213,15 +223,16 @@ class ZumApiClient(
val exportUrl = contentObj["exportUrl"]?.jsonPrimitive?.contentOrNull ?: appUrl
// Breitgefächerter Abruf aller HTML-Linktexte (Kategorien, Schulstufen, Tags)
val tags =
Regex("""href="[^"]*(?:taxonomy|fach|tags|schulstufe)[^"]*"[^>]*>([^<]+)<""")
Regex("""<a[^>]*>([^<]+)</a>""")
.findAll(html)
.map { it.groupValues[1].trim() }
.filter { it.isNotBlank() }
.filter { it.isNotBlank() && it.length < 60 }
.toList()
val subject = inferSubject(title, tags, fallbackSubject)
val grade = inferGrade(title, tags)
val grade = inferGrade(title, html, tags)
return ZumOerItem(
id = "zum-$appId",
@ -259,26 +270,47 @@ class ZumApiClient(
private fun inferGrade(
title: String,
html: String,
tags: List<String>,
): Int? {
val combined = (tags + title).joinToString(" ")
val gradeMatch = Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr)\b""", RegexOption.IGNORE_CASE).find(combined)
if (gradeMatch != null) {
return gradeMatch.groupValues[1].toIntOrNull()
}
if (combined.contains("Primarstufe", ignoreCase = true) || combined.contains("Grundschule", ignoreCase = true)) {
return 3
}
if (combined.contains("Sekundarstufe I", ignoreCase = true) || combined.contains("Sek I", ignoreCase = true)) {
return 6
}
val primaryText = (tags + title).joinToString(" ")
// 1. Muster: "3. Klasse", "3. Schuljahr", "3. Kl."
Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE)
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
// 2. Muster: "Klasse 3", "Kl. 3", "Schuljahr 3"
Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE)
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
// 3. Muster: "Klasse 3-4", "Klasse 3/4", "3.-4. Klasse"
Regex("""\b(?:Klasse|Kl\.)?\s*(\d+)\s*[-/]\s*(\d+)\b""", RegexOption.IGNORE_CASE)
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
// 4. Fallback: Gesamten HTML-Text der Seite nach Klassenangaben absuchen
Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE)
.find(html)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE)
.find(html)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
// 5. Fallback: Schulstufen-Schlüsselwörter
val combinedLower = (primaryText + " " + html).lowercase()
if (combinedLower.contains("primarstufe") || combinedLower.contains("grundschule")) return 3
val isSek1 =
combinedLower.contains("sekundarstufe i") ||
combinedLower.contains("sek i") ||
combinedLower.contains("realschule") ||
combinedLower.contains("hauptschule")
if (isSek1) return 6
val isSek2 =
combined.contains("Sekundarstufe II", ignoreCase = true) ||
combined.contains("Gymnasium", ignoreCase = true) ||
combined.contains("Abitur", ignoreCase = true)
if (isSek2) {
return 10
}
combinedLower.contains("sekundarstufe ii") ||
combinedLower.contains("sek ii") ||
combinedLower.contains("gymnasium") ||
combinedLower.contains("abitur")
if (isSek2) return 10
return null
}

View file

@ -96,11 +96,19 @@ fun Application.configureRouting(
authenticate("auth-jwt") {
// 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
get("/api/v1/tasks") {
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId
val subject = call.request.queryParameters["subject"]
val age = call.request.queryParameters["age"]?.toIntOrNull()
// Expliziter Query-Parameter 'age' ODER Fallback auf das Alter des Users
val queryAge = call.request.queryParameters["age"]?.toIntOrNull()
val userAge = userId?.let { repository.getUserById(it)?.childAge }
val effectiveAge = queryAge ?: userAge
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 10
val tasks = repository.getTasks(subject = subject, age = age, limit = limit)
val tasks = repository.getTasks(subject = subject, age = effectiveAge, limit = limit)
call.respond(tasks)
}
@ -110,7 +118,10 @@ fun Application.configureRouting(
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId
if (userId == null) {
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiges oder abgelaufenes Token"))
call.respond(
HttpStatusCode.Unauthorized,
mapOf("error" to "Ungültiges oder abgelaufenes Token"),
)
return@post
}
val taskInfo = repository.getTaskAnswerAndReward(request.taskId)
@ -150,6 +161,7 @@ fun Application.configureRouting(
)
}
}
get("/api/v1/screentime") {
val principal = call.principal<UserPrincipal>()
val userid =
@ -174,7 +186,8 @@ fun Application.configureRouting(
// Wir holen die echte, fälschungssichere userId aus dem Login-Token:
val principal = call.principal<UserPrincipal>()
val userId = principal?.userId ?: request.userId // Fallback auf request.userId, falls token noch fehlt
val userId =
principal?.userId ?: request.userId // Fallback auf request.userId, falls token noch fehlt
val currentSeconds = repository.getScreentime(userId)
var newSeconds = currentSeconds - request.usedSecondsSinceLastSync