feat/adding-h5p-tasks #10
|
|
@ -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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue
Eigene Aufgaben rausnehmen oder in eine JSON Datei packen