From b082df962fab312d4f9e78ba65634079703733df Mon Sep 17 00:00:00 2001 From: Oliver Wallisch Date: Tue, 15 Sep 2026 11:38:36 +0200 Subject: [PATCH] fix: fixed h5p integration and api call for the age --- .../domain/repository/KidioRepository.kt | 50 +++++++++++ .../backend/ingestion/H5PIngestionCli.kt | 12 ++- .../kidio/backend/ingestion/ZumApiClient.kt | 86 +++++++++++++------ .../oliver/kidio/backend/plugins/Routing.kt | 21 ++++- 4 files changed, 137 insertions(+), 32 deletions(-) diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt index 360e3dd..988365c 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt @@ -18,12 +18,17 @@ import org.mindrot.jbcrypt.BCrypt interface KidioRepository { suspend fun getAllTasks(): List = getTasks(limit = 100) + suspend fun getUserById(userId: String): User? + suspend fun getTasks( subject: String? = null, age: Int? = null, limit: Int = 10, ): List + // Mit suspend markiert, damit es zur DatabaseFactory.dbQuery Architektur passt + suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set + suspend fun upsertTask(task: ImportedTask): Boolean suspend fun getTaskAnswerAndReward(taskId: String): Pair? @@ -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 = getTasks(limit = 100) + // NEU: Implementierung für Exposed (PostgreSQL) + override suspend fun getExistingTaskIds(externalSource: String): Set = + 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( 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 { + 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) diff --git a/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PIngestionCli.kt b/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PIngestionCli.kt index 6ed6b2b..70e2d99 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PIngestionCli.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PIngestionCli.kt @@ -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() diff --git a/src/main/kotlin/com/oliver/kidio/backend/ingestion/ZumApiClient.kt b/src/main/kotlin/com/oliver/kidio/backend/ingestion/ZumApiClient.kt index 89315a9..eec9bf1 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/ingestion/ZumApiClient.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/ingestion/ZumApiClient.kt @@ -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 = emptySet(), ): List { val results = mutableListOf() val seenAppIds = mutableSetOf() @@ -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("""]*>([^<]+)""") .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, ): 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 } diff --git a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt index f1991f9..3b82b15 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt @@ -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() + 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() 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() val userid = @@ -174,7 +186,8 @@ fun Application.configureRouting( // Wir holen die echte, fälschungssichere userId aus dem Login-Token: val principal = call.principal() - 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