feat/adding-h5p-tasks #10
|
|
@ -18,12 +18,17 @@ import org.mindrot.jbcrypt.BCrypt
|
||||||
interface KidioRepository {
|
interface KidioRepository {
|
||||||
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
||||||
|
|
||||||
|
suspend fun getUserById(userId: String): User?
|
||||||
|
|
||||||
suspend fun getTasks(
|
suspend fun getTasks(
|
||||||
subject: String? = null,
|
subject: String? = null,
|
||||||
age: Int? = null,
|
age: Int? = null,
|
||||||
limit: Int = 10,
|
limit: Int = 10,
|
||||||
): List<Task>
|
): 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 upsertTask(task: ImportedTask): Boolean
|
||||||
|
|
||||||
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
|
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)
|
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 =
|
override suspend fun upsertTask(task: ImportedTask): Boolean =
|
||||||
DatabaseFactory.dbQuery {
|
DatabaseFactory.dbQuery {
|
||||||
try {
|
try {
|
||||||
|
|
@ -237,25 +269,31 @@ class InMemoryKidioRepository : KidioRepository {
|
||||||
mutableListOf(
|
mutableListOf(
|
||||||
ImportedTask(
|
ImportedTask(
|
||||||
|
DrunkenPinguin marked this conversation as resolved
|
|||||||
id = "t1",
|
id = "t1",
|
||||||
|
externalSource = "manual",
|
||||||
subject = "Mathematik",
|
subject = "Mathematik",
|
||||||
minAge = 6,
|
minAge = 6,
|
||||||
maxAge = 10,
|
maxAge = 10,
|
||||||
|
taskType = "MULTIPLE_CHOICE",
|
||||||
question = "Was ist 12 + 15?",
|
question = "Was ist 12 + 15?",
|
||||||
options = listOf("25", "27", "30", "22"),
|
options = listOf("25", "27", "30", "22"),
|
||||||
correctAnswer = "27",
|
correctAnswer = "27",
|
||||||
rewardMinutes = 10,
|
rewardMinutes = 10,
|
||||||
license = "CC BY-SA 4.0",
|
license = "CC BY-SA 4.0",
|
||||||
|
sourceUrl = "local",
|
||||||
),
|
),
|
||||||
ImportedTask(
|
ImportedTask(
|
||||||
id = "t2",
|
id = "t2",
|
||||||
|
externalSource = "manual",
|
||||||
subject = "Mathematik",
|
subject = "Mathematik",
|
||||||
minAge = 7,
|
minAge = 7,
|
||||||
maxAge = 12,
|
maxAge = 12,
|
||||||
|
taskType = "MULTIPLE_CHOICE",
|
||||||
question = "Was ist 8 x 7?",
|
question = "Was ist 8 x 7?",
|
||||||
options = listOf("54", "56", "64", "48"),
|
options = listOf("54", "56", "64", "48"),
|
||||||
correctAnswer = "56",
|
correctAnswer = "56",
|
||||||
rewardMinutes = 15,
|
rewardMinutes = 15,
|
||||||
license = "CC BY-SA 4.0",
|
license = "CC BY-SA 4.0",
|
||||||
|
sourceUrl = "local",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
private val screentimes =
|
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 {
|
override suspend fun upsertTask(task: ImportedTask): Boolean {
|
||||||
tasks.removeIf { it.id == task.id }
|
tasks.removeIf { it.id == task.id }
|
||||||
tasks.add(task)
|
tasks.add(task)
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,18 @@ object H5PIngestionCli {
|
||||||
limit: Int = 50,
|
limit: Int = 50,
|
||||||
): Int =
|
): Int =
|
||||||
runBlocking {
|
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
|
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) {
|
for (item in items) {
|
||||||
var parsedList = emptyList<ParsedH5PTask>()
|
var parsedList = emptyList<ParsedH5PTask>()
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,14 @@ class ZumApiClient(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches live OER items from apps.zum.de via search and Drupal settings extraction.
|
* 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.
|
* Falls back to curated items if remote catalog is unreachable or returns 0 matching interactive items.
|
||||||
*/
|
*/
|
||||||
fun fetchLiveOerItems(
|
fun fetchLiveOerItems(
|
||||||
subject: String? = null,
|
subject: String? = null,
|
||||||
limit: Int = 50,
|
limit: Int = 50,
|
||||||
maxPages: Int = 5,
|
maxPages: Int = 15,
|
||||||
|
existingIds: Set<String> = emptySet(),
|
||||||
): List<ZumOerItem> {
|
): List<ZumOerItem> {
|
||||||
val results = mutableListOf<ZumOerItem>()
|
val results = mutableListOf<ZumOerItem>()
|
||||||
val seenAppIds = mutableSetOf<String>()
|
val seenAppIds = mutableSetOf<String>()
|
||||||
|
|
@ -126,17 +128,21 @@ class ZumApiClient(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtert bereits verarbeitete oder in DB vorhandene Apps heraus
|
||||||
val appIds =
|
val appIds =
|
||||||
Regex("""href=["']/apps/(\d+)["']""")
|
Regex("""href=["']/apps/(\d+)["']""")
|
||||||
.findAll(html)
|
.findAll(html)
|
||||||
.map { it.groupValues[1] }
|
.map { it.groupValues[1] }
|
||||||
.distinct()
|
.distinct()
|
||||||
.filter { it !in seenAppIds }
|
.filter { appId ->
|
||||||
|
val fullId = "zum-$appId"
|
||||||
|
!seenAppIds.contains(appId) && !existingIds.contains(fullId) && !existingIds.contains(appId)
|
||||||
|
}
|
||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
if (appIds.isEmpty()) {
|
if (appIds.isEmpty()) {
|
||||||
println("[ZUM-Apps Live] Keine weiteren Apps auf Seite $page gefunden.")
|
println("[ZUM-Apps Live] Seite $page enthält keine neuen Aufgaben. Blättere weiter...")
|
||||||
break
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for (appId in appIds) {
|
for (appId in appIds) {
|
||||||
|
|
@ -146,17 +152,21 @@ class ZumApiClient(
|
||||||
val item = fetchAppDetails(appId, subject)
|
val item = fetchAppDetails(appId, subject)
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
results.add(item)
|
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()) {
|
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)
|
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
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,15 +223,16 @@ class ZumApiClient(
|
||||||
|
|
||||||
val exportUrl = contentObj["exportUrl"]?.jsonPrimitive?.contentOrNull ?: appUrl
|
val exportUrl = contentObj["exportUrl"]?.jsonPrimitive?.contentOrNull ?: appUrl
|
||||||
|
|
||||||
|
// Breitgefächerter Abruf aller HTML-Linktexte (Kategorien, Schulstufen, Tags)
|
||||||
val tags =
|
val tags =
|
||||||
Regex("""href="[^"]*(?:taxonomy|fach|tags|schulstufe)[^"]*"[^>]*>([^<]+)<""")
|
Regex("""<a[^>]*>([^<]+)</a>""")
|
||||||
.findAll(html)
|
.findAll(html)
|
||||||
.map { it.groupValues[1].trim() }
|
.map { it.groupValues[1].trim() }
|
||||||
.filter { it.isNotBlank() }
|
.filter { it.isNotBlank() && it.length < 60 }
|
||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
val subject = inferSubject(title, tags, fallbackSubject)
|
val subject = inferSubject(title, tags, fallbackSubject)
|
||||||
val grade = inferGrade(title, tags)
|
val grade = inferGrade(title, html, tags)
|
||||||
|
|
||||||
return ZumOerItem(
|
return ZumOerItem(
|
||||||
id = "zum-$appId",
|
id = "zum-$appId",
|
||||||
|
|
@ -259,26 +270,47 @@ class ZumApiClient(
|
||||||
|
|
||||||
private fun inferGrade(
|
private fun inferGrade(
|
||||||
title: String,
|
title: String,
|
||||||
|
html: String,
|
||||||
tags: List<String>,
|
tags: List<String>,
|
||||||
): Int? {
|
): Int? {
|
||||||
val combined = (tags + title).joinToString(" ")
|
val primaryText = (tags + title).joinToString(" ")
|
||||||
val gradeMatch = Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr)\b""", RegexOption.IGNORE_CASE).find(combined)
|
|
||||||
if (gradeMatch != null) {
|
// 1. Muster: "3. Klasse", "3. Schuljahr", "3. Kl."
|
||||||
return gradeMatch.groupValues[1].toIntOrNull()
|
Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE)
|
||||||
}
|
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||||
if (combined.contains("Primarstufe", ignoreCase = true) || combined.contains("Grundschule", ignoreCase = true)) {
|
|
||||||
return 3
|
// 2. Muster: "Klasse 3", "Kl. 3", "Schuljahr 3"
|
||||||
}
|
Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE)
|
||||||
if (combined.contains("Sekundarstufe I", ignoreCase = true) || combined.contains("Sek I", ignoreCase = true)) {
|
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||||
return 6
|
|
||||||
}
|
// 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 =
|
val isSek2 =
|
||||||
combined.contains("Sekundarstufe II", ignoreCase = true) ||
|
combinedLower.contains("sekundarstufe ii") ||
|
||||||
combined.contains("Gymnasium", ignoreCase = true) ||
|
combinedLower.contains("sek ii") ||
|
||||||
combined.contains("Abitur", ignoreCase = true)
|
combinedLower.contains("gymnasium") ||
|
||||||
if (isSek2) {
|
combinedLower.contains("abitur")
|
||||||
return 10
|
if (isSek2) return 10
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,11 +96,19 @@ fun Application.configureRouting(
|
||||||
authenticate("auth-jwt") {
|
authenticate("auth-jwt") {
|
||||||
// 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
|
// 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
|
||||||
get("/api/v1/tasks") {
|
get("/api/v1/tasks") {
|
||||||
|
val principal = call.principal<UserPrincipal>()
|
||||||
|
val userId = principal?.userId
|
||||||
|
|
||||||
val subject = call.request.queryParameters["subject"]
|
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 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)
|
call.respond(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +118,10 @@ fun Application.configureRouting(
|
||||||
val principal = call.principal<UserPrincipal>()
|
val principal = call.principal<UserPrincipal>()
|
||||||
val userId = principal?.userId
|
val userId = principal?.userId
|
||||||
if (userId == null) {
|
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
|
return@post
|
||||||
}
|
}
|
||||||
val taskInfo = repository.getTaskAnswerAndReward(request.taskId)
|
val taskInfo = repository.getTaskAnswerAndReward(request.taskId)
|
||||||
|
|
@ -150,6 +161,7 @@ fun Application.configureRouting(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/api/v1/screentime") {
|
get("/api/v1/screentime") {
|
||||||
val principal = call.principal<UserPrincipal>()
|
val principal = call.principal<UserPrincipal>()
|
||||||
val userid =
|
val userid =
|
||||||
|
|
@ -174,7 +186,8 @@ fun Application.configureRouting(
|
||||||
|
|
||||||
// Wir holen die echte, fälschungssichere userId aus dem Login-Token:
|
// Wir holen die echte, fälschungssichere userId aus dem Login-Token:
|
||||||
val principal = call.principal<UserPrincipal>()
|
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)
|
val currentSeconds = repository.getScreentime(userId)
|
||||||
var newSeconds = currentSeconds - request.usedSecondsSinceLastSync
|
var newSeconds = currentSeconds - request.usedSecondsSinceLastSync
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue
Eigene Aufgaben rausnehmen oder in eine JSON Datei packen