From 6d82a82945ced8702a907381d48cd6bedfb35eb2 Mon Sep 17 00:00:00 2001 From: Stevek11 Date: Tue, 16 Dec 2025 08:19:37 +0100 Subject: [PATCH 01/21] Add basic fetching --- build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + settings.gradle.kts | 2 +- .../io/github/tomhula/jecnaapi/JecnaClient.kt | 44 ++++++++++++- .../data/substitution/Substitution.kt | 24 +++++++ .../jecnaapi/data/timetable/LessonSpot.kt | 6 +- .../jecnaapi/data/timetable/Timetable.kt | 63 ++++++++++++++++++- .../jecnaapi/data/timetable/TimetablePage.kt | 7 ++- 8 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt diff --git a/build.gradle.kts b/build.gradle.kts index c22b6040..35c1df18 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,7 @@ allprojects { dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.serialization.json) implementation(libs.jsoup) api(libs.ktor.client.core) implementation(libs.ktor.client.cio) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab0fa8a8..c70e6a60 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ ktor = "3.3.3" [libraries] kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6a74e7e8..40e70c03 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,7 +2,7 @@ rootProject.name = "jecnaapi" include("jecnaapi-java") plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } dependencyResolutionManagement { diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt index eb736852..34502933 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt @@ -5,6 +5,7 @@ import io.ktor.http.* import io.github.tomhula.jecnaapi.data.notification.NotificationReference import io.github.tomhula.jecnaapi.data.schoolStaff.TeacherReference import io.github.tomhula.jecnaapi.data.timetable.TimetablePage +import io.github.tomhula.jecnaapi.data.substitution.SubstitutionResponse import io.github.tomhula.jecnaapi.parser.parsers.* import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder.jecnaEncode @@ -16,6 +17,7 @@ import io.github.tomhula.jecnaapi.web.AuthenticationException import io.github.tomhula.jecnaapi.web.append import io.github.tomhula.jecnaapi.web.jecna.JecnaWebClient import io.github.tomhula.jecnaapi.web.jecna.Role +import kotlinx.serialization.json.Json import java.time.Month import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -84,13 +86,48 @@ class JecnaClient( suspend fun getGradesPage() = gradesPageParser.parse(queryStringBody(PageWebPath.grades)) - suspend fun getTimetablePage(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null) = - timetablePageParser.parse(queryStringBody(PageWebPath.timetable, Parameters.build { + suspend fun getTimetablePage(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null): TimetablePage + { + val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable, Parameters.build { append(schoolYear.jecnaEncode()) periodOption?.let { append(it.jecnaEncode()) } })) + return fetchAndMergeSubstitutions(page) + } + + suspend fun getTimetablePage(): TimetablePage + { + val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable)) + return fetchAndMergeSubstitutions(page) + } + + private suspend fun fetchAndMergeSubstitutions(page: TimetablePage): TimetablePage + { + return try + { + val substitutions = getSubstitutions() + val profile = getStudentProfile() + val className = profile.className + if (className != null) + { + page.mergeSubstitutions(substitutions, className) + } + else + { + page + } + } + catch (e: Exception) + { + page + } + } - suspend fun getTimetablePage() = timetablePageParser.parse(queryStringBody(PageWebPath.timetable)) + suspend fun getSubstitutions(): SubstitutionResponse + { + val response = webClient.plainQuery(PageWebPath.SUBSTITUTION_ENDPOINT) + return Json { ignoreUnknownKeys = true }.decodeFromString(response.bodyAsText()) + } suspend fun getAttendancesPage(schoolYear: SchoolYear, month: Month) = getAttendancesPage(schoolYear, month.value) @@ -169,6 +206,7 @@ class JecnaClient( const val recordList = "/user-student/record-list" const val student = "/student" const val locker = "/locker/student" + const val SUBSTITUTION_ENDPOINT = "https://jecnarozvrh.jzitnik.dev/" } } } diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt new file mode 100644 index 00000000..4331a0ca --- /dev/null +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt @@ -0,0 +1,24 @@ +package io.github.tomhula.jecnaapi.data.substitution + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class SubstitutionResponse( + val schedule: List>, + val props: List, + val status: SubstitutionStatus +) + +@Serializable +data class SubstitutionProp( + val date: String, + val priprava: Boolean +) + +@Serializable +data class SubstitutionStatus( + val lastUpdated: String, + val currentUpdateSchedule: Int +) + diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/LessonSpot.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/LessonSpot.kt index 3ddfc507..88a4eca0 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/LessonSpot.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/LessonSpot.kt @@ -11,11 +11,12 @@ import io.github.tomhula.jecnaapi.util.hasDuplicate * This class indicates the one whole lesson and contains the lessons for each group. * * @property periodSpan The number of [periods][LessonPeriod] this lesson spot spans over. + * @property substitution The substitution text for this lesson spot, or `null` if there is no substitution. */ @Serializable -class LessonSpot(val lessons: List, val periodSpan: Int = 1) : Iterable +class LessonSpot(val lessons: List, val periodSpan: Int = 1, val substitution: String? = null) : Iterable { - constructor(lesson: Lesson, periodSpan: Int = 1) : this(listOf(lesson), periodSpan) + constructor(lesson: Lesson, periodSpan: Int = 1, substitution: String? = null) : this(listOf(lesson), periodSpan, substitution) /** The number of lessons in this [LessonSpot]. */ @Transient @@ -52,6 +53,7 @@ class LessonSpot(val lessons: List, val periodSpan: Int = 1) : Iterable< return "LessonSpot{" + "lessons=" + lessons + ", periodSpan=" + periodSpan + + ", substitution=" + substitution + '}' } diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/Timetable.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/Timetable.kt index c5a8282d..6f712dfc 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/Timetable.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/Timetable.kt @@ -2,15 +2,19 @@ package io.github.tomhula.jecnaapi.data.timetable import kotlinx.serialization.Serializable import io.github.tomhula.jecnaapi.serialization.TimetableSerializer +import io.github.tomhula.jecnaapi.data.substitution.SubstitutionResponse import io.github.tomhula.jecnaapi.util.emptyMutableLinkedList import io.github.tomhula.jecnaapi.util.next import io.github.tomhula.jecnaapi.util.setAll +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive import java.time.* import java.time.temporal.ChronoUnit import java.util.* @Serializable(with = TimetableSerializer::class) -class Timetable private constructor( +class Timetable internal constructor( lessonPeriods: List, private val timetable: Map> ) @@ -85,6 +89,63 @@ class Timetable private constructor( /** Returns a list of all [LessonSpots][LessonSpot] in the given [day] ordered by their start time, or `null` if [day] is not in this [Timetable]. */ operator fun get(day: DayOfWeek) = getLessonSpotsForDay(day) + /** + * Returns a new [Timetable] with substitutions applied. + * @param substitutionResponse The substitutions to apply. + * @param className The class name to filter substitutions for. + */ + fun withSubstitutions(substitutionResponse: SubstitutionResponse, className: String): Timetable + { + val newTimetable = timetable.mapValues { it.value.toMutableList() }.toMutableMap() + + substitutionResponse.schedule.forEachIndexed { index, daySchedule -> + val prop = substitutionResponse.props.getOrNull(index) ?: return@forEachIndexed + val date = LocalDate.parse(prop.date) + val dayOfWeek = date.dayOfWeek + + if (newTimetable.containsKey(dayOfWeek)) + { + val lessonSpots = newTimetable[dayOfWeek]!! + val classSubstitutions = daySchedule[className] + + if (classSubstitutions is JsonArray) + { + classSubstitutions.forEachIndexed { periodIndex, element -> + if (element !is JsonNull && element is JsonPrimitive && element.isString) + { + val substitutionText = element.content + + // Find the LessonSpot at this periodIndex + var x = 0 + var spotIndex = -1 + for ((i, spot) in lessonSpots.withIndex()) + { + if (periodIndex in x until x + spot.periodSpan) + { + spotIndex = i + break + } + x += spot.periodSpan + } + + if (spotIndex != -1) + { + val originalSpot = lessonSpots[spotIndex] + val newSubstitution = if (originalSpot.substitution != null) + originalSpot.substitution + "\n" + substitutionText + else + substitutionText + + lessonSpots[spotIndex] = LessonSpot(originalSpot.lessons, originalSpot.periodSpan, newSubstitution) + } + } + } + } + } + } + return Timetable(lessonPeriods, newTimetable) + } + /** Returns the [LessonSpot] that is happening at the given [lessonPeriodIndex]. */ fun getLessonSpot(day: DayOfWeek, lessonPeriodIndex: Int): LessonSpot? { diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt index 73b56c8a..89c4a385 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt @@ -2,6 +2,7 @@ package io.github.tomhula.jecnaapi.data.timetable import kotlinx.serialization.Serializable import io.github.tomhula.jecnaapi.serialization.LocalDateSerializer +import io.github.tomhula.jecnaapi.data.substitution.SubstitutionResponse import io.github.tomhula.jecnaapi.util.SchoolYear import io.github.tomhula.jecnaapi.util.emptyMutableLinkedList import io.github.tomhula.jecnaapi.util.setAll @@ -13,7 +14,7 @@ import java.time.format.DateTimeFormatter */ @ConsistentCopyVisibility @Serializable -data class TimetablePage private constructor( +data class TimetablePage internal constructor( val timetable: Timetable, val periodOptions: List = emptyList(), val selectedSchoolYear: SchoolYear @@ -21,6 +22,10 @@ data class TimetablePage private constructor( { override fun toString() = "TimetablePage(timetable=$timetable, periodOptions=$periodOptions, selectedSchoolYear=$selectedSchoolYear)" + fun mergeSubstitutions(substitutionResponse: SubstitutionResponse, className: String): TimetablePage { + return copy(timetable = timetable.withSubstitutions(substitutionResponse, className)) + } + companion object { @JvmStatic From ecb6f251160b83e39214632e96e1e384502aff65 Mon Sep 17 00:00:00 2001 From: Stevek11 Date: Tue, 16 Dec 2025 08:39:59 +0100 Subject: [PATCH 02/21] Split each data class into its own file --- .../jecnaapi/data/substitution/Substitution.kt | 13 ------------- .../jecnaapi/data/substitution/SubstitutionProp.kt | 14 ++++++++++++++ .../data/substitution/SubstitutionStatus.kt | 10 ++++++++++ 3 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionProp.kt create mode 100644 src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt index 4331a0ca..00904a8e 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt @@ -9,16 +9,3 @@ data class SubstitutionResponse( val props: List, val status: SubstitutionStatus ) - -@Serializable -data class SubstitutionProp( - val date: String, - val priprava: Boolean -) - -@Serializable -data class SubstitutionStatus( - val lastUpdated: String, - val currentUpdateSchedule: Int -) - diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionProp.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionProp.kt new file mode 100644 index 00000000..263c3a8c --- /dev/null +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionProp.kt @@ -0,0 +1,14 @@ +package io.github.tomhula.jecnaapi.data.substitution +import kotlinx.serialization.Serializable +/** + * Properties related to substitutions, such as preparation status and date. + */ +@Serializable +data class SubstitutionProp( + val priprava: Boolean, + val date: String, +) + + + + diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt new file mode 100644 index 00000000..f4efa2ce --- /dev/null +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt @@ -0,0 +1,10 @@ +package io.github.tomhula.jecnaapi.data.substitution + +import kotlinx.serialization.Serializable + +@Serializable +data class SubstitutionStatus( + val lastUpdated: String, + val currentUpdateSchedule: Int +) + From e037d6d9e2e7af9330cc7d06853486bae22da082 Mon Sep 17 00:00:00 2001 From: Stevek11 Date: Tue, 16 Dec 2025 08:43:48 +0100 Subject: [PATCH 03/21] Add bool for choice --- .../io/github/tomhula/jecnaapi/JecnaClient.kt | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt index 34502933..8597e049 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt @@ -38,8 +38,10 @@ class JecnaClient( var autoLogin by webClient::autoLogin val userAgent by webClient::userAgent + /** The last [time][java.time.Instant] a call to [login] was successful (returned `true`). */ val lastSuccessfulLoginTime by webClient::lastSuccessfulLoginTime + /** * [Auth] used by [autoLogin]. Is automatically updated by [login] on a successful login. * Is set to `null` on [logout]. @@ -86,19 +88,31 @@ class JecnaClient( suspend fun getGradesPage() = gradesPageParser.parse(queryStringBody(PageWebPath.grades)) - suspend fun getTimetablePage(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null): TimetablePage + suspend fun getTimetablePage( + schoolYear: SchoolYear, + periodOption: TimetablePage.PeriodOption? = null, + withSubtitution: Boolean + ): TimetablePage { val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable, Parameters.build { append(schoolYear.jecnaEncode()) periodOption?.let { append(it.jecnaEncode()) } })) - return fetchAndMergeSubstitutions(page) + if (withSubtitution) + { + return fetchAndMergeSubstitutions(page) + } + return page } - suspend fun getTimetablePage(): TimetablePage + suspend fun getTimetablePage(withSubtitution: Boolean): TimetablePage { val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable)) - return fetchAndMergeSubstitutions(page) + if (withSubtitution) + { + return fetchAndMergeSubstitutions(page) + } + return page } private suspend fun fetchAndMergeSubstitutions(page: TimetablePage): TimetablePage @@ -116,8 +130,7 @@ class JecnaClient( { page } - } - catch (e: Exception) + } catch (e: Exception) { page } @@ -148,9 +161,11 @@ class JecnaClient( suspend fun getTeachersPage() = teachersPageParser.parse(queryStringBody(PageWebPath.teachers)) - suspend fun getTeacher(teacherTag: String) = teacherParser.parse(queryStringBody("${PageWebPath.teachers}/$teacherTag")) + suspend fun getTeacher(teacherTag: String) = + teacherParser.parse(queryStringBody("${PageWebPath.teachers}/$teacherTag")) - suspend fun getTeacher(teacherReference: TeacherReference) = teacherParser.parse(queryStringBody("${PageWebPath.teachers}/${teacherReference.tag}")) + suspend fun getTeacher(teacherReference: TeacherReference) = + teacherParser.parse(queryStringBody("${PageWebPath.teachers}/${teacherReference.tag}")) /** * Gets the locker information for the currently logged in student. @@ -158,12 +173,14 @@ class JecnaClient( */ suspend fun getLocker() = lockerPageParser.parse(queryStringBody(PageWebPath.locker)) - suspend fun getStudentProfile(username: String) = studentProfileParser.parse(queryStringBody("${PageWebPath.student}/$username")) + suspend fun getStudentProfile(username: String) = + studentProfileParser.parse(queryStringBody("${PageWebPath.student}/$username")) - suspend fun getStudentProfile() = autoLoginAuth?.let { getStudentProfile(it.username)} + suspend fun getStudentProfile() = autoLoginAuth?.let { getStudentProfile(it.username) } ?: throw AuthenticationException() - suspend fun getNotification(notification: NotificationReference) = notificationParser.getNotification(queryStringBody("${PageWebPath.records}?userStudentRecordId=${notification.recordId}")) + suspend fun getNotification(notification: NotificationReference) = + notificationParser.getNotification(queryStringBody("${PageWebPath.records}?userStudentRecordId=${notification.recordId}")) suspend fun getNotifications() = notificationParser.parse(queryStringBody(PageWebPath.recordList)) @@ -187,7 +204,8 @@ class JecnaClient( * @throws AuthenticationException When the query fails because user is not authenticated. * @return The [HttpResponse]. */ - suspend fun queryStringBody(path: String, parameters: Parameters? = null) = webClient.queryStringBody(path, parameters) + suspend fun queryStringBody(path: String, parameters: Parameters? = null) = + webClient.queryStringBody(path, parameters) /** Closes the HTTP client. */ fun close() = webClient.close() From 3179c6b11d7e3025519591ba6585f849bb95489d Mon Sep 17 00:00:00 2001 From: Stevek11 Date: Tue, 16 Dec 2025 09:02:01 +0100 Subject: [PATCH 04/21] Add teacher absence parsing --- .../io/github/tomhula/jecnaapi/JecnaClient.kt | 11 ++++ .../data/substitution/AbsenceHours.kt | 9 ++++ .../data/substitution/Substitution.kt | 51 +++++++++++++++++++ .../data/substitution/TeacherAbsence.kt | 12 +++++ 4 files changed, 83 insertions(+) create mode 100644 src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt create mode 100644 src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt index 8597e049..20dc7f82 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt @@ -6,6 +6,7 @@ import io.github.tomhula.jecnaapi.data.notification.NotificationReference import io.github.tomhula.jecnaapi.data.schoolStaff.TeacherReference import io.github.tomhula.jecnaapi.data.timetable.TimetablePage import io.github.tomhula.jecnaapi.data.substitution.SubstitutionResponse +import io.github.tomhula.jecnaapi.data.substitution.TeacherAbsence import io.github.tomhula.jecnaapi.parser.parsers.* import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder.jecnaEncode @@ -142,6 +143,16 @@ class JecnaClient( return Json { ignoreUnknownKeys = true }.decodeFromString(response.bodyAsText()) } + /** + * Returns teacher absences from the substitution endpoint. + * The list index corresponds to the same index as in [SubstitutionResponse.props] + * (i.e. each inner list is one day). + */ + suspend fun getTeacherAbsences(): List> + { + return getSubstitutions().absencesByDay + } + suspend fun getAttendancesPage(schoolYear: SchoolYear, month: Month) = getAttendancesPage(schoolYear, month.value) suspend fun getAttendancesPage(schoolYear: SchoolYear, month: Int) = diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt new file mode 100644 index 00000000..487529ab --- /dev/null +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt @@ -0,0 +1,9 @@ +package io.github.tomhula.jecnaapi.data.substitution + +import kotlinx.serialization.Serializable + +@Serializable +data class AbsenceHours( + val from: Int, + val to: Int +) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt index 00904a8e..56707f69 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt @@ -1,11 +1,62 @@ package io.github.tomhula.jecnaapi.data.substitution import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +/** + * Response containing substitution schedule data. + */ @Serializable data class SubstitutionResponse( val schedule: List>, val props: List, val status: SubstitutionStatus ) +{ + /** + * Returns absences for each day (same index as in [schedule]/[props]). + */ + val absencesByDay: List> by lazy { + schedule.map { dayEntry -> + val rawAbsences = dayEntry["ABSENCE"] + if (rawAbsences == null || rawAbsences is JsonNull) emptyList() else + when (rawAbsences) + { + is JsonArray -> rawAbsences.mapNotNull { it.toTeacherAbsenceOrNull() } + else -> emptyList() + } + } + } +} + +private fun JsonElement.toTeacherAbsenceOrNull(): TeacherAbsence? +{ + if (this !is JsonObject) return null + + val teacher = this["teacher"]?.let { it.toString().trim('"') } + val teacherCode = this["teacherCode"]?.let { it.toString().trim('"') } ?: return null + val type = this["type"]?.let { it.toString().trim('"') } ?: return null + val hoursEl = this["hours"] + + val hours = if (hoursEl == null || hoursEl is JsonNull) + null + else + { + val obj = hoursEl.jsonObject + val from = obj["from"]?.toString()?.toIntOrNull() + val to = obj["to"]?.toString()?.toIntOrNull() + if (from != null && to != null) AbsenceHours(from, to) else null + } + + return TeacherAbsence( + teacher = teacher, + teacherCode = teacherCode, + type = type, + hours = hours + ) +} diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt new file mode 100644 index 00000000..67e1fa62 --- /dev/null +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt @@ -0,0 +1,12 @@ +package io.github.tomhula.jecnaapi.data.substitution + +import kotlinx.serialization.Serializable + +@Serializable +data class TeacherAbsence( + val teacher: String?, + val teacherCode: String, + val type: String, + val hours: AbsenceHours? +) + From b8a10932455eefa4380cbdb281000494e7d79dc9 Mon Sep 17 00:00:00 2001 From: Stevek Date: Tue, 16 Dec 2025 21:29:35 +0100 Subject: [PATCH 05/21] Fix typo --- src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt index 20dc7f82..73a91cfb 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt @@ -92,24 +92,24 @@ class JecnaClient( suspend fun getTimetablePage( schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null, - withSubtitution: Boolean + withSubstitution: Boolean ): TimetablePage { val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable, Parameters.build { append(schoolYear.jecnaEncode()) periodOption?.let { append(it.jecnaEncode()) } })) - if (withSubtitution) + if (withSubstitution) { return fetchAndMergeSubstitutions(page) } return page } - suspend fun getTimetablePage(withSubtitution: Boolean): TimetablePage + suspend fun getTimetablePage(withSubstitution: Boolean): TimetablePage { val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable)) - if (withSubtitution) + if (withSubstitution) { return fetchAndMergeSubstitutions(page) } From 259213c43b29e9920f1176f30c4c7868fd34b036 Mon Sep 17 00:00:00 2001 From: Stevek Date: Tue, 16 Dec 2025 21:34:25 +0100 Subject: [PATCH 06/21] Json fix --- src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt index 73a91cfb..c135bfa9 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt @@ -23,6 +23,8 @@ import java.time.Month import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +private val json = Json { ignoreUnknownKeys = true } + /** * A client to access Jecna Web data. * @@ -137,10 +139,12 @@ class JecnaClient( } } + + suspend fun getSubstitutions(): SubstitutionResponse { val response = webClient.plainQuery(PageWebPath.SUBSTITUTION_ENDPOINT) - return Json { ignoreUnknownKeys = true }.decodeFromString(response.bodyAsText()) + return json.decodeFromString(response.bodyAsText()) } /** From 1129253f8d4455f5017e83b6f3ca319db9fb3af6 Mon Sep 17 00:00:00 2001 From: Stevek Date: Tue, 16 Dec 2025 21:45:07 +0100 Subject: [PATCH 07/21] getTeacherAbsences() now always returns labeled absences for ease of use --- .../io/github/tomhula/jecnaapi/JecnaClient.kt | 15 +++++----- .../data/substitution/Substitution.kt | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt index c135bfa9..e36d080d 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt @@ -7,6 +7,7 @@ import io.github.tomhula.jecnaapi.data.schoolStaff.TeacherReference import io.github.tomhula.jecnaapi.data.timetable.TimetablePage import io.github.tomhula.jecnaapi.data.substitution.SubstitutionResponse import io.github.tomhula.jecnaapi.data.substitution.TeacherAbsence +import io.github.tomhula.jecnaapi.data.substitution.LabeledTeacherAbsences import io.github.tomhula.jecnaapi.parser.parsers.* import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder.jecnaEncode @@ -139,22 +140,22 @@ class JecnaClient( } } - - suspend fun getSubstitutions(): SubstitutionResponse { val response = webClient.plainQuery(PageWebPath.SUBSTITUTION_ENDPOINT) return json.decodeFromString(response.bodyAsText()) } + /** - * Returns teacher absences from the substitution endpoint. - * The list index corresponds to the same index as in [SubstitutionResponse.props] - * (i.e. each inner list is one day). + * Returns teacher absences from the substitution endpoint, labeled by date. + * + * Each element corresponds to one day and contains the date (from [SubstitutionProp.date]) + * together with the list of [TeacherAbsence] for that day. */ - suspend fun getTeacherAbsences(): List> + suspend fun getTeacherAbsences(): List { - return getSubstitutions().absencesByDay + return getSubstitutions().labeledAbsencesByDay } suspend fun getAttendancesPage(schoolYear: SchoolYear, month: Month) = getAttendancesPage(schoolYear, month.value) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt index 56707f69..f4ddcb1c 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt @@ -32,8 +32,36 @@ data class SubstitutionResponse( } } } + + /** + * Returns absences for each day labeled with the corresponding date from [props]. + * + * The list index still corresponds to the same index as in [schedule]/[props], but each + * element carries the date label for easier consumption. + */ + val labeledAbsencesByDay: List by lazy { + props.mapIndexed { index, prop -> + val absences = absencesByDay.getOrNull(index).orEmpty() + LabeledTeacherAbsences( + date = prop.date, + absences = absences + ) + } + } } +/** + * Container for teacher absences on a specific day. + * + * @property date The date string associated with this day, as provided by [SubstitutionProp]. + * @property absences List of [TeacherAbsence] for this date. + */ +@Serializable +data class LabeledTeacherAbsences( + val date: String, + val absences: List +) + private fun JsonElement.toTeacherAbsenceOrNull(): TeacherAbsence? { if (this !is JsonObject) return null From c3acc2a7561f8a6386f9a4761f2de82857d9b316 Mon Sep 17 00:00:00 2001 From: Stevek Date: Tue, 16 Dec 2025 21:52:01 +0100 Subject: [PATCH 08/21] add overloads for backward compatibility --- .../io/github/tomhula/jecnaapi/JecnaClient.kt | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt index e36d080d..cb0f65ce 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt @@ -109,14 +109,32 @@ class JecnaClient( return page } + suspend fun getTimetablePage( + schoolYear: SchoolYear, + periodOption: TimetablePage.PeriodOption? = null, + ): TimetablePage + { + val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable, Parameters.build { + append(schoolYear.jecnaEncode()) + periodOption?.let { append(it.jecnaEncode()) } + })) + return page + } + + suspend fun getTimetablePage(): TimetablePage + { + val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable)) + return page + } + suspend fun getTimetablePage(withSubstitution: Boolean): TimetablePage { val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable)) - if (withSubstitution) + if (!withSubstitution) { - return fetchAndMergeSubstitutions(page) + return page } - return page + return fetchAndMergeSubstitutions(page) } private suspend fun fetchAndMergeSubstitutions(page: TimetablePage): TimetablePage From bef84c9f44c8177bd078ecd19115ec48354f3e44 Mon Sep 17 00:00:00 2001 From: Stevek Date: Thu, 18 Dec 2025 21:41:00 +0100 Subject: [PATCH 09/21] Handle endpoint down --- .../io/github/tomhula/jecnaapi/JecnaClient.kt | 48 ++++++++++++++++--- .../data/substitution/Substitution.kt | 11 +++-- .../data/substitution/SubstitutionStatus.kt | 4 +- .../data/substitution/TeacherAbsence.kt | 3 +- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt index cb0f65ce..8ba14378 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt @@ -14,6 +14,7 @@ import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder.jecnaEncode import io.github.tomhula.jecnaapi.util.SchoolYear import io.github.tomhula.jecnaapi.util.SchoolYearHalf import io.github.tomhula.jecnaapi.data.student.Locker +import io.github.tomhula.jecnaapi.data.substitution.SubstitutionStatus import io.github.tomhula.jecnaapi.web.Auth import io.github.tomhula.jecnaapi.web.AuthenticationException import io.github.tomhula.jecnaapi.web.append @@ -67,7 +68,7 @@ class JecnaClient( suspend fun login(username: String, password: String) = login(Auth(username, password)) suspend fun login(auth: Auth) = webClient.login(auth) - + suspend fun logout() = webClient.logout() suspend fun isLoggedIn() = webClient.isLoggedIn() @@ -160,20 +161,55 @@ class JecnaClient( suspend fun getSubstitutions(): SubstitutionResponse { - val response = webClient.plainQuery(PageWebPath.SUBSTITUTION_ENDPOINT) - return json.decodeFromString(response.bodyAsText()) + return try + { + val response = webClient.plainQuery(PageWebPath.SUBSTITUTION_ENDPOINT) + json.decodeFromString(response.bodyAsText()) + } + catch (e: Exception) + { + SubstitutionResponse( + schedule = emptyList(), + props = emptyList(), + status = SubstitutionStatus( + lastUpdated = "", + currentUpdateSchedule = 0, + message = "Endpoint na suplování je nyní nedostupný!" + ) + ) + } } /** * Returns teacher absences from the substitution endpoint, labeled by date. * - * Each element corresponds to one day and contains the date (from [SubstitutionProp.date]) + * Each element corresponds to one day and contains the date label (see [LabeledTeacherAbsences.date]) * together with the list of [TeacherAbsence] for that day. */ suspend fun getTeacherAbsences(): List { - return getSubstitutions().labeledAbsencesByDay + return try + { + getSubstitutions().labeledAbsencesByDay + } + catch (e: Exception) + { + listOf( + LabeledTeacherAbsences( + date = "(unknown date)", + absences = listOf( + TeacherAbsence( + teacher = null, + teacherCode = "", + type = "", + hours = null, + message = getSubstitutions().status.message + ) + ) + ) + ) + } } suspend fun getAttendancesPage(schoolYear: SchoolYear, month: Month) = getAttendancesPage(schoolYear, month.value) @@ -258,7 +294,7 @@ class JecnaClient( const val recordList = "/user-student/record-list" const val student = "/student" const val locker = "/locker/student" - const val SUBSTITUTION_ENDPOINT = "https://jecnarozvrh.jzitnik.dev/" + const val SUBSTITUTION_ENDPOINT = "https://jecnarozvrh.jzitnik.dev/versioned/v1" } } } diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt index f4ddcb1c..6e35d191 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt @@ -5,7 +5,6 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject /** @@ -40,10 +39,13 @@ data class SubstitutionResponse( * element carries the date label for easier consumption. */ val labeledAbsencesByDay: List by lazy { - props.mapIndexed { index, prop -> + // Prefer schedule length as the source of truth so every day has a label, + // even if props is missing/misaligned. + schedule.indices.map { index -> + val dateLabel = props.getOrNull(index)?.date ?: "(unknown date)" val absences = absencesByDay.getOrNull(index).orEmpty() LabeledTeacherAbsences( - date = prop.date, + date = dateLabel, absences = absences ) } @@ -62,6 +64,9 @@ data class LabeledTeacherAbsences( val absences: List ) +/** + * Details about a teacher's absence. + */ private fun JsonElement.toTeacherAbsenceOrNull(): TeacherAbsence? { if (this !is JsonObject) return null diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt index f4efa2ce..7f0ce9f2 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt @@ -5,6 +5,6 @@ import kotlinx.serialization.Serializable @Serializable data class SubstitutionStatus( val lastUpdated: String, - val currentUpdateSchedule: Int + val currentUpdateSchedule: Int, + val message: String? = null //when down ) - diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt index 67e1fa62..f208f7a3 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt @@ -7,6 +7,7 @@ data class TeacherAbsence( val teacher: String?, val teacherCode: String, val type: String, - val hours: AbsenceHours? + val hours: AbsenceHours?, + val message: String? = null // Add a message field to handle endpoint unavailability - as suggested by zitnik ) From 6643f17c9b4d150390a4ed93aeeb9151ad2459a7 Mon Sep 17 00:00:00 2001 From: Stevek Date: Thu, 18 Dec 2025 21:47:32 +0100 Subject: [PATCH 10/21] fix --- .../io/github/tomhula/jecnaapi/JecnaClient.kt | 39 ++++++++++--------- .../jecnaapi/data/timetable/TimetablePage.kt | 11 ++++-- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt index 8ba14378..db46a997 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt @@ -151,7 +151,7 @@ class JecnaClient( } else { - page + page.copy(substitutionMessage = substitutions.status.message) } } catch (e: Exception) { @@ -189,27 +189,30 @@ class JecnaClient( */ suspend fun getTeacherAbsences(): List { - return try - { - getSubstitutions().labeledAbsencesByDay - } - catch (e: Exception) - { - listOf( - LabeledTeacherAbsences( - date = "(unknown date)", - absences = listOf( - TeacherAbsence( - teacher = null, - teacherCode = "", - type = "", - hours = null, - message = getSubstitutions().status.message + val substitutions = getSubstitutions() + + // If substitutions endpoint is down, getSubstitutions() returns an empty response with status.message + substitutions.status.message?.let { msg -> + if (substitutions.schedule.isEmpty() && substitutions.props.isEmpty()) + { + return listOf( + LabeledTeacherAbsences( + date = "(unknown date)", + absences = listOf( + TeacherAbsence( + teacher = null, + teacherCode = "", + type = "", + hours = null, + message = msg + ) ) ) ) - ) + } } + + return substitutions.labeledAbsencesByDay } suspend fun getAttendancesPage(schoolYear: SchoolYear, month: Month) = getAttendancesPage(schoolYear, month.value) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt index 89c4a385..fb51b0e7 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt @@ -17,13 +17,18 @@ import java.time.format.DateTimeFormatter data class TimetablePage internal constructor( val timetable: Timetable, val periodOptions: List = emptyList(), - val selectedSchoolYear: SchoolYear + val selectedSchoolYear: SchoolYear, + + val substitutionMessage: String? = null ) { - override fun toString() = "TimetablePage(timetable=$timetable, periodOptions=$periodOptions, selectedSchoolYear=$selectedSchoolYear)" + override fun toString() = "TimetablePage(timetable=$timetable, periodOptions=$periodOptions, selectedSchoolYear=$selectedSchoolYear, substitutionMessage=$substitutionMessage)" fun mergeSubstitutions(substitutionResponse: SubstitutionResponse, className: String): TimetablePage { - return copy(timetable = timetable.withSubstitutions(substitutionResponse, className)) + return copy( + timetable = timetable.withSubstitutions(substitutionResponse, className), + substitutionMessage = substitutionResponse.status.message + ) } companion object From 543ef219be551fa335b8528a3dcc08bb02d9090a Mon Sep 17 00:00:00 2001 From: Stevek Date: Thu, 18 Dec 2025 22:14:07 +0100 Subject: [PATCH 11/21] Improvements --- .../data/substitution/AbsenceHours.kt | 8 ++++- .../data/substitution/Substitution.kt | 31 +++++++++++++------ .../data/substitution/TeacherAbsence.kt | 15 +++++++-- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt index 487529ab..88d02115 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt @@ -2,8 +2,14 @@ package io.github.tomhula.jecnaapi.data.substitution import kotlinx.serialization.Serializable +/** + * Represents hours affected by a teacher absence. + * + * - Range: from/to set (e.g. 2-4) + * - Single: only [from] set (e.g. 3) + */ @Serializable data class AbsenceHours( val from: Int, - val to: Int + val to: Int? = null ) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt index 6e35d191..0da72c85 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt @@ -5,6 +5,9 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject /** @@ -71,19 +74,27 @@ private fun JsonElement.toTeacherAbsenceOrNull(): TeacherAbsence? { if (this !is JsonObject) return null - val teacher = this["teacher"]?.let { it.toString().trim('"') } - val teacherCode = this["teacherCode"]?.let { it.toString().trim('"') } ?: return null - val type = this["type"]?.let { it.toString().trim('"') } ?: return null + val teacher = this["teacher"]?.toString()?.trim('"') + val teacherCode = this["teacherCode"]?.toString()?.trim('"') + val type = this["type"]?.toString()?.trim('"') ?: return null val hoursEl = this["hours"] - val hours = if (hoursEl == null || hoursEl is JsonNull) - null - else + val hours = when { - val obj = hoursEl.jsonObject - val from = obj["from"]?.toString()?.toIntOrNull() - val to = obj["to"]?.toString()?.toIntOrNull() - if (from != null && to != null) AbsenceHours(from, to) else null + hoursEl == null || hoursEl is JsonNull -> null + hoursEl is JsonPrimitive && hoursEl.intOrNull != null -> AbsenceHours(hoursEl.int, null) + else -> + { + val obj = hoursEl.jsonObject + val from = obj["from"]?.toString()?.toIntOrNull() + val to = obj["to"]?.toString()?.toIntOrNull() + when + { + from == null -> null + to == null -> AbsenceHours(from, null) + else -> AbsenceHours(from, to) + } + } } return TeacherAbsence( diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt index f208f7a3..7d3eb7ef 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt @@ -5,9 +5,18 @@ import kotlinx.serialization.Serializable @Serializable data class TeacherAbsence( val teacher: String?, - val teacherCode: String, + val teacherCode: String?, + /** + * Absence type, e.g. wholeDay/single/range/exkurze/invalid. + */ val type: String, val hours: AbsenceHours?, - val message: String? = null // Add a message field to handle endpoint unavailability - as suggested by zitnik + /** + * Original token/segment that couldn't be parsed (used when [type] is "invalid"). + */ + val original: String? = null, + /** + * Optional user-facing message (e.g. when the substitution endpoint is down). + */ + val message: String? = null ) - From 73fb9bab822e4b53d627d83b2611f22313e7a51d Mon Sep 17 00:00:00 2001 From: stevek Date: Sat, 27 Dec 2025 19:28:18 +0100 Subject: [PATCH 12/21] Make code more modular and move to a separate class --- .../io/github/tomhula/jecnaapi/JecnaClient.kt | 144 +++--------------- .../jecnaapi/service/SubstitutionService.kt | 118 ++++++++++++++ 2 files changed, 139 insertions(+), 123 deletions(-) create mode 100644 src/main/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt index db46a997..ee0312b7 100644 --- a/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt @@ -1,12 +1,12 @@ package io.github.tomhula.jecnaapi +import io.github.tomhula.jecnaapi.service.SubstitutionService import io.ktor.client.statement.* import io.ktor.http.* import io.github.tomhula.jecnaapi.data.notification.NotificationReference import io.github.tomhula.jecnaapi.data.schoolStaff.TeacherReference import io.github.tomhula.jecnaapi.data.timetable.TimetablePage import io.github.tomhula.jecnaapi.data.substitution.SubstitutionResponse -import io.github.tomhula.jecnaapi.data.substitution.TeacherAbsence import io.github.tomhula.jecnaapi.data.substitution.LabeledTeacherAbsences import io.github.tomhula.jecnaapi.parser.parsers.* import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder @@ -14,19 +14,15 @@ import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder.jecnaEncode import io.github.tomhula.jecnaapi.util.SchoolYear import io.github.tomhula.jecnaapi.util.SchoolYearHalf import io.github.tomhula.jecnaapi.data.student.Locker -import io.github.tomhula.jecnaapi.data.substitution.SubstitutionStatus import io.github.tomhula.jecnaapi.web.Auth import io.github.tomhula.jecnaapi.web.AuthenticationException import io.github.tomhula.jecnaapi.web.append import io.github.tomhula.jecnaapi.web.jecna.JecnaWebClient import io.github.tomhula.jecnaapi.web.jecna.Role -import kotlinx.serialization.json.Json import java.time.Month import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -private val json = Json { ignoreUnknownKeys = true } - /** * A client to access Jecna Web data. * @@ -40,13 +36,12 @@ class JecnaClient( ) { private val webClient = JecnaWebClient(requestTimout, autoLogin, userAgent) + private val substitutionService = SubstitutionService(webClient) var autoLogin by webClient::autoLogin val userAgent by webClient::userAgent - /** The last [time][java.time.Instant] a call to [login] was successful (returned `true`). */ val lastSuccessfulLoginTime by webClient::lastSuccessfulLoginTime - /** * [Auth] used by [autoLogin]. Is automatically updated by [login] on a successful login. * Is set to `null` on [logout]. @@ -68,7 +63,7 @@ class JecnaClient( suspend fun login(username: String, password: String) = login(Auth(username, password)) suspend fun login(auth: Auth) = webClient.login(auth) - + suspend fun logout() = webClient.logout() suspend fun isLoggedIn() = webClient.isLoggedIn() @@ -93,127 +88,36 @@ class JecnaClient( suspend fun getGradesPage() = gradesPageParser.parse(queryStringBody(PageWebPath.grades)) - suspend fun getTimetablePage( - schoolYear: SchoolYear, - periodOption: TimetablePage.PeriodOption? = null, - withSubstitution: Boolean - ): TimetablePage - { + suspend fun getTimetablePage(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null, withSubstitution: Boolean = false): TimetablePage { val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable, Parameters.build { append(schoolYear.jecnaEncode()) periodOption?.let { append(it.jecnaEncode()) } })) - if (withSubstitution) - { - return fetchAndMergeSubstitutions(page) + return if (withSubstitution) { + substitutionService.fetchAndMergeSubstitutions(page) { getStudentProfile().className } + } else { + page } - return page - } - - suspend fun getTimetablePage( - schoolYear: SchoolYear, - periodOption: TimetablePage.PeriodOption? = null, - ): TimetablePage - { - val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable, Parameters.build { - append(schoolYear.jecnaEncode()) - periodOption?.let { append(it.jecnaEncode()) } - })) - return page } - suspend fun getTimetablePage(): TimetablePage - { - val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable)) - return page - } - - suspend fun getTimetablePage(withSubstitution: Boolean): TimetablePage - { + suspend fun getTimetablePage(withSubstitution: Boolean = false): TimetablePage { val page = timetablePageParser.parse(queryStringBody(PageWebPath.timetable)) - if (!withSubstitution) - { - return page - } - return fetchAndMergeSubstitutions(page) - } - - private suspend fun fetchAndMergeSubstitutions(page: TimetablePage): TimetablePage - { - return try - { - val substitutions = getSubstitutions() - val profile = getStudentProfile() - val className = profile.className - if (className != null) - { - page.mergeSubstitutions(substitutions, className) - } - else - { - page.copy(substitutionMessage = substitutions.status.message) - } - } catch (e: Exception) - { + return if (withSubstitution) { + substitutionService.fetchAndMergeSubstitutions(page) { getStudentProfile().className } + } else { page } } - suspend fun getSubstitutions(): SubstitutionResponse - { - return try - { - val response = webClient.plainQuery(PageWebPath.SUBSTITUTION_ENDPOINT) - json.decodeFromString(response.bodyAsText()) - } - catch (e: Exception) - { - SubstitutionResponse( - schedule = emptyList(), - props = emptyList(), - status = SubstitutionStatus( - lastUpdated = "", - currentUpdateSchedule = 0, - message = "Endpoint na suplování je nyní nedostupný!" - ) - ) - } - } + suspend fun getSubstitutions(): SubstitutionResponse = substitutionService.getSubstitutions() - /** * Returns teacher absences from the substitution endpoint, labeled by date. * * Each element corresponds to one day and contains the date label (see [LabeledTeacherAbsences.date]) - * together with the list of [TeacherAbsence] for that day. + * together with the list of absences for that day. */ - suspend fun getTeacherAbsences(): List - { - val substitutions = getSubstitutions() - - // If substitutions endpoint is down, getSubstitutions() returns an empty response with status.message - substitutions.status.message?.let { msg -> - if (substitutions.schedule.isEmpty() && substitutions.props.isEmpty()) - { - return listOf( - LabeledTeacherAbsences( - date = "(unknown date)", - absences = listOf( - TeacherAbsence( - teacher = null, - teacherCode = "", - type = "", - hours = null, - message = msg - ) - ) - ) - ) - } - } - - return substitutions.labeledAbsencesByDay - } + suspend fun getTeacherAbsences(): List = substitutionService.getTeacherAbsences() suspend fun getAttendancesPage(schoolYear: SchoolYear, month: Month) = getAttendancesPage(schoolYear, month.value) @@ -234,11 +138,9 @@ class JecnaClient( suspend fun getTeachersPage() = teachersPageParser.parse(queryStringBody(PageWebPath.teachers)) - suspend fun getTeacher(teacherTag: String) = - teacherParser.parse(queryStringBody("${PageWebPath.teachers}/$teacherTag")) + suspend fun getTeacher(teacherTag: String) = teacherParser.parse(queryStringBody("${PageWebPath.teachers}/$teacherTag")) - suspend fun getTeacher(teacherReference: TeacherReference) = - teacherParser.parse(queryStringBody("${PageWebPath.teachers}/${teacherReference.tag}")) + suspend fun getTeacher(teacherReference: TeacherReference) = teacherParser.parse(queryStringBody("${PageWebPath.teachers}/${teacherReference.tag}")) /** * Gets the locker information for the currently logged in student. @@ -246,14 +148,12 @@ class JecnaClient( */ suspend fun getLocker() = lockerPageParser.parse(queryStringBody(PageWebPath.locker)) - suspend fun getStudentProfile(username: String) = - studentProfileParser.parse(queryStringBody("${PageWebPath.student}/$username")) + suspend fun getStudentProfile(username: String) = studentProfileParser.parse(queryStringBody("${PageWebPath.student}/$username")) - suspend fun getStudentProfile() = autoLoginAuth?.let { getStudentProfile(it.username) } + suspend fun getStudentProfile() = autoLoginAuth?.let { getStudentProfile(it.username)} ?: throw AuthenticationException() - suspend fun getNotification(notification: NotificationReference) = - notificationParser.getNotification(queryStringBody("${PageWebPath.records}?userStudentRecordId=${notification.recordId}")) + suspend fun getNotification(notification: NotificationReference) = notificationParser.getNotification(queryStringBody("${PageWebPath.records}?userStudentRecordId=${notification.recordId}")) suspend fun getNotifications() = notificationParser.parse(queryStringBody(PageWebPath.recordList)) @@ -277,8 +177,7 @@ class JecnaClient( * @throws AuthenticationException When the query fails because user is not authenticated. * @return The [HttpResponse]. */ - suspend fun queryStringBody(path: String, parameters: Parameters? = null) = - webClient.queryStringBody(path, parameters) + suspend fun queryStringBody(path: String, parameters: Parameters? = null) = webClient.queryStringBody(path, parameters) /** Closes the HTTP client. */ fun close() = webClient.close() @@ -297,7 +196,6 @@ class JecnaClient( const val recordList = "/user-student/record-list" const val student = "/student" const val locker = "/locker/student" - const val SUBSTITUTION_ENDPOINT = "https://jecnarozvrh.jzitnik.dev/versioned/v1" } } } diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt b/src/main/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt new file mode 100644 index 00000000..a5219462 --- /dev/null +++ b/src/main/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt @@ -0,0 +1,118 @@ +package io.github.tomhula.jecnaapi.service + +import io.ktor.client.statement.* +import io.github.tomhula.jecnaapi.data.substitution.SubstitutionResponse +import io.github.tomhula.jecnaapi.data.substitution.SubstitutionStatus +import io.github.tomhula.jecnaapi.data.substitution.TeacherAbsence +import io.github.tomhula.jecnaapi.data.substitution.LabeledTeacherAbsences +import io.github.tomhula.jecnaapi.data.timetable.TimetablePage +import io.github.tomhula.jecnaapi.web.jecna.JecnaWebClient +import kotlinx.serialization.json.Json + +private val json = Json { ignoreUnknownKeys = true } + +/** + * Service for managing substitutions and teacher absences. + * Handles fetching, parsing, and merging substitution data. + */ +class SubstitutionService(private val webClient: JecnaWebClient) { + + /** + * Fetches substitutions from the remote endpoint and deserializes them. + * Returns an error response if the endpoint is unavailable. + */ + suspend fun getSubstitutions(): SubstitutionResponse { + return try { + val response = webClient.plainQuery(SUBSTITUTION_ENDPOINT) + json.decodeFromString(response.bodyAsText()) + } catch (e: Exception) { + createSubstitutionErrorResponse() + } + } + + /** + * Fetches substitutions and merges them into a timetable page. + * Returns the original page if merging fails. + */ + suspend fun fetchAndMergeSubstitutions( + page: TimetablePage, + getStudentClassNameProvider: suspend () -> String? + ): TimetablePage { + return try { + val substitutions = getSubstitutions() + val className = getStudentClassNameProvider() + mergeSubstitutionsIntoPage(page, substitutions, className) + } catch (e: Exception) { + page + } + } + + /** + * Merges substitution data into a timetable page. + * If className is available, performs full merge; otherwise sets substitution message. + */ + private fun mergeSubstitutionsIntoPage( + page: TimetablePage, + substitutions: SubstitutionResponse, + className: String? + ): TimetablePage = if (className != null) { + page.mergeSubstitutions(substitutions, className) + } else { + page.copy(substitutionMessage = substitutions.status.message) + } + + /** + * Retrieves teacher absences from the substitution endpoint, labeled by date. + * Each element corresponds to one day with the date label and list of absences. + */ + suspend fun getTeacherAbsences(): List { + val substitutions = getSubstitutions() + return if (isSubstitutionEndpointDown(substitutions)) { + createErrorAbsenceResponse(substitutions.status.message) + } else { + substitutions.labeledAbsencesByDay + } + } + + /** + * Checks if the substitution endpoint is down based on response characteristics. + */ + private fun isSubstitutionEndpointDown(substitutions: SubstitutionResponse): Boolean = + substitutions.schedule.isEmpty() && substitutions.props.isEmpty() && substitutions.status.message != null + + /** + * Creates a default error response when substitution endpoint is unavailable. + */ + private fun createSubstitutionErrorResponse(): SubstitutionResponse = SubstitutionResponse( + schedule = emptyList(), + props = emptyList(), + status = SubstitutionStatus( + lastUpdated = "", + currentUpdateSchedule = 0, + message = "Endpoint na suplování je nyní nedostupný!" + ) + ) + + /** + * Creates a placeholder error absence response when endpoint is down. + */ + private fun createErrorAbsenceResponse(message: String?): List = listOf( + LabeledTeacherAbsences( + date = "(unknown date)", + absences = listOf( + TeacherAbsence( + teacher = null, + teacherCode = "", + type = "", + hours = null, + message = message ?: "Unknown error" + ) + ) + ) + ) + + companion object { + private const val SUBSTITUTION_ENDPOINT = "https://jecnarozvrh.jzitnik.dev/versioned/v1" + } +} + From 81abc2a95e8c62ed792ad1acd20c2953fcb10ba0 Mon Sep 17 00:00:00 2001 From: stevek Date: Sat, 27 Dec 2025 19:29:57 +0100 Subject: [PATCH 13/21] Add Java wrapper --- .../jecnaapi/java/JecnaClientJavaWrapper.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/jecnaapi-java/src/main/java/io/github/tomhula/jecnaapi/java/JecnaClientJavaWrapper.kt b/jecnaapi-java/src/main/java/io/github/tomhula/jecnaapi/java/JecnaClientJavaWrapper.kt index 64dd3a81..6079d9a3 100644 --- a/jecnaapi-java/src/main/java/io/github/tomhula/jecnaapi/java/JecnaClientJavaWrapper.kt +++ b/jecnaapi-java/src/main/java/io/github/tomhula/jecnaapi/java/JecnaClientJavaWrapper.kt @@ -1,10 +1,5 @@ package io.github.tomhula.jecnaapi.java -import io.ktor.client.statement.* -import io.ktor.http.* -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.future.future import io.github.tomhula.jecnaapi.JecnaClient import io.github.tomhula.jecnaapi.data.notification.NotificationReference import io.github.tomhula.jecnaapi.data.schoolStaff.TeacherReference @@ -14,6 +9,11 @@ import io.github.tomhula.jecnaapi.util.SchoolYearHalf import io.github.tomhula.jecnaapi.web.Auth import io.github.tomhula.jecnaapi.web.AuthenticationException import io.github.tomhula.jecnaapi.web.jecna.Role +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.future.future import java.time.Month /** @@ -53,9 +53,18 @@ class JecnaClientJavaWrapper(autoLogin: Boolean = false) fun getTimetablePage() = GlobalScope.future { wrappedClient.getTimetablePage() } + fun getTimetablePage(withSubstitution: Boolean) = GlobalScope.future { wrappedClient.getTimetablePage(withSubstitution) } + fun getTimetablePage(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null) = GlobalScope.future { wrappedClient.getTimetablePage(schoolYear, periodOption) } + fun getTimetablePage(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null, withSubstitution: Boolean) = + GlobalScope.future { wrappedClient.getTimetablePage(schoolYear, periodOption, withSubstitution) } + + fun getSubstitutions() = GlobalScope.future { wrappedClient.getSubstitutions() } + + fun getTeacherAbsences() = GlobalScope.future { wrappedClient.getTeacherAbsences() } + fun getAttendancePage() = GlobalScope.future { wrappedClient.getAttendancesPage() } fun getAttendancePage(schoolYear: SchoolYear, month: Int) = From 74ae8e11bcc4173fb5427bbfa235b16f28da78e3 Mon Sep 17 00:00:00 2001 From: Stevek Date: Tue, 20 Jan 2026 20:32:57 +0100 Subject: [PATCH 14/21] put on commonMain --- .../io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt | 0 .../io/github/tomhula/jecnaapi/data/substitution/Substitution.kt | 0 .../github/tomhula/jecnaapi/data/substitution/SubstitutionProp.kt | 0 .../tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt | 0 .../github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt | 0 .../io/github/tomhula/jecnaapi/service/SubstitutionService.kt | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename src/{main => commonMain}/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt (100%) rename src/{main => commonMain}/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt (100%) rename src/{main => commonMain}/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionProp.kt (100%) rename src/{main => commonMain}/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt (100%) rename src/{main => commonMain}/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt (100%) rename src/{main => commonMain}/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt (100%) diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt similarity index 100% rename from src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt rename to src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt similarity index 100% rename from src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt rename to src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionProp.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionProp.kt similarity index 100% rename from src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionProp.kt rename to src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionProp.kt diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt similarity index 100% rename from src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt rename to src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt similarity index 100% rename from src/main/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt rename to src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt diff --git a/src/main/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt similarity index 100% rename from src/main/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt rename to src/commonMain/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt From b8551f6d9e619ed005876184a2ef1f0e100ca01c Mon Sep 17 00:00:00 2001 From: Stevek Date: Tue, 20 Jan 2026 20:37:35 +0100 Subject: [PATCH 15/21] add json --- build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle.kts b/build.gradle.kts index 0d92d0fa..cb3f90cc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,6 +41,7 @@ kotlin { implementation(libs.ksoup) api(libs.kotlinx.datetime) api(libs.ktor.client.core) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") } commonTest.dependencies { implementation(kotlin("test")) From f7092cbb588d9051b75335c6a33b61ca14d6eb1c Mon Sep 17 00:00:00 2001 From: Stevek Date: Tue, 20 Jan 2026 21:10:36 +0100 Subject: [PATCH 16/21] fix --- .../tomhula/jecnaapi/data/timetable/Timetable.kt | 13 ++++--------- .../jecnaapi/data/timetable/TimetablePage.kt | 1 - 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/Timetable.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/Timetable.kt index cb287c39..af53d464 100644 --- a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/Timetable.kt +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/Timetable.kt @@ -3,16 +3,13 @@ package io.github.tomhula.jecnaapi.data.timetable import kotlinx.serialization.Serializable import io.github.tomhula.jecnaapi.serialization.TimetableSerializer import io.github.tomhula.jecnaapi.data.substitution.SubstitutionResponse -import io.github.tomhula.jecnaapi.util.emptyMutableLinkedList import io.github.tomhula.jecnaapi.util.next import io.github.tomhula.jecnaapi.util.setAll import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive -import java.time.* -import java.time.temporal.ChronoUnit -import java.util.* import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone @@ -72,9 +69,7 @@ class Timetable internal constructor( /** Returns index of the [LessonPeriod] at the given [time], or `null` if there is not any. */ fun getIndexOfLessonPeriod(time: LocalTime) = lessonPeriods.indexOfFirst { time in it }.let { if (it != -1) it else null } - - /** Returns index of the [LessonPeriod] at [LocalTime.now], or `null` if there is not any. */ - fun getIndexOfCurrentLessonPeriod() = getIndexOfLessonPeriod(LocalTime.now()) + /** Returns the [LessonPeriod] at the given [time], or `null` if there is not any. */ fun getLessonPeriod(time: LocalTime) = getIndexOfLessonPeriod(time)?.let { lessonPeriods[it] } @@ -95,13 +90,13 @@ class Timetable internal constructor( } /** Returns the index of next [LessonPeriod] from [LocalTime.now], or `null` if there is no next [LessonPeriod]. */ - fun getIndexOfCurrentNextLessonPeriod() = getIndexOfNextLessonPeriod(LocalTime.now()) + fun getIndexOfCurrentNextLessonPeriod() = getIndexOfNextLessonPeriod(nowLocalTime()) /** Returns the next [LessonPeriod] from the given [time], or `null` if there is no next [LessonPeriod]. */ fun getNextLessonPeriod(time: LocalTime) = getIndexOfNextLessonPeriod(time)?.let { lessonPeriods[it] } /** Returns the next [LessonPeriod] from [LocalTime.now], or `null` if there is no next [LessonPeriod]. */ - fun getCurrentNextLessonPeriod() = getNextLessonPeriod(LocalTime.now()) + fun getCurrentNextLessonPeriod() = getNextLessonPeriod(nowLocalTime()) /** Returns a list of all [LessonSpots][LessonSpot] in the given [day] ordered by their start time, or `null` if [day] is not in this [Timetable]. */ fun getLessonSpotsForDay(day: DayOfWeek) = timetable[day] diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt index 866b0210..e64f566c 100644 --- a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt @@ -1,7 +1,6 @@ package io.github.tomhula.jecnaapi.data.timetable import kotlinx.serialization.Serializable -import io.github.tomhula.jecnaapi.serialization.LocalDateSerializer import io.github.tomhula.jecnaapi.data.substitution.SubstitutionResponse import io.github.tomhula.jecnaapi.util.SchoolYear import io.github.tomhula.jecnaapi.util.setAll From 3914e567c86cf4e520c1f998b75d4e35b121d4ed Mon Sep 17 00:00:00 2001 From: Stevek Date: Tue, 20 Jan 2026 21:21:35 +0100 Subject: [PATCH 17/21] add runTest --- build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle.kts b/build.gradle.kts index cb3f90cc..ebbab0f1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,6 +45,7 @@ kotlin { } commonTest.dependencies { implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") } jvmMain.dependencies { runtimeOnly(libs.ktor.client.engine.java) From 4c9ab0d9fb48f3ccc2c0f9c637a41ebc050e6e47 Mon Sep 17 00:00:00 2001 From: Stevek Date: Wed, 21 Jan 2026 14:17:28 +0100 Subject: [PATCH 18/21] add back substitutions into the new interface. --- .../io/github/tomhula/jecnaapi/JecnaClient.kt | 15 ++++++--------- .../io/github/tomhula/jecnaapi/WebJecnaClient.kt | 8 ++++++++ .../jecnaapi/service/SubstitutionService.kt | 4 ++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt index e137f330..35b555d7 100644 --- a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt @@ -7,22 +7,15 @@ import io.github.tomhula.jecnaapi.data.notification.Notification import io.github.tomhula.jecnaapi.data.notification.NotificationReference import io.github.tomhula.jecnaapi.data.room.Room import io.github.tomhula.jecnaapi.data.room.RoomReference -import io.github.tomhula.jecnaapi.service.SubstitutionService -import io.ktor.client.statement.* -import io.ktor.http.* -import io.github.tomhula.jecnaapi.data.notification.NotificationReference import io.github.tomhula.jecnaapi.data.room.RoomsPage import io.github.tomhula.jecnaapi.data.schoolStaff.Teacher import io.github.tomhula.jecnaapi.data.schoolStaff.TeacherReference import io.github.tomhula.jecnaapi.data.schoolStaff.TeachersPage import io.github.tomhula.jecnaapi.data.student.Locker import io.github.tomhula.jecnaapi.data.student.Student -import io.github.tomhula.jecnaapi.data.timetable.TimetablePage -import io.github.tomhula.jecnaapi.data.substitution.SubstitutionResponse import io.github.tomhula.jecnaapi.data.substitution.LabeledTeacherAbsences -import io.github.tomhula.jecnaapi.parser.parsers.* -import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder -import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder.jecnaEncode +import io.github.tomhula.jecnaapi.data.substitution.SubstitutionResponse +import io.github.tomhula.jecnaapi.data.timetable.TimetablePage import io.github.tomhula.jecnaapi.util.SchoolYear import io.github.tomhula.jecnaapi.util.SchoolYearHalf import io.github.tomhula.jecnaapi.web.Auth @@ -56,6 +49,10 @@ interface JecnaClient suspend fun getStudentProfile(username: String): Student suspend fun getNotifications(): List suspend fun getNotification(notification: NotificationReference): Notification + suspend fun getSubstitutions(): SubstitutionResponse + suspend fun getTeacherAbsences(): List + suspend fun getTimetablePageWithSubstitutions(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null): TimetablePage + suspend fun getTimetablePageWithSubstitutions(): TimetablePage companion object { diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/WebJecnaClient.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/WebJecnaClient.kt index 9ef7094c..7cb0035e 100644 --- a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/WebJecnaClient.kt +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/WebJecnaClient.kt @@ -7,6 +7,7 @@ import io.ktor.http.* import io.github.tomhula.jecnaapi.data.notification.NotificationReference import io.github.tomhula.jecnaapi.data.timetable.TimetablePage import io.github.tomhula.jecnaapi.parser.parsers.* +import io.github.tomhula.jecnaapi.service.SubstitutionService import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder.jecnaEncode import io.github.tomhula.jecnaapi.util.SchoolYear @@ -81,6 +82,7 @@ class WebJecnaClient( private val lockerPageParser = LockerPageParser private val roomsPageParser = RoomsPageParser private val roomParser = RoomParser(TimetableParser) + private val substitutionService = SubstitutionService(this) @OptIn(ExperimentalTime::class) override suspend fun login(auth: Auth): Boolean @@ -159,6 +161,12 @@ class WebJecnaClient( override suspend fun getStudentProfile() = autoLoginAuth?.let { getStudentProfile(it.username)} ?: throw AuthenticationException() override suspend fun getNotification(notification: NotificationReference) = notificationParser.getNotification(queryStringBody("${PageWebPath.NOTIFICATION}?userStudentRecordId=${notification.recordId}")) override suspend fun getNotifications() = notificationParser.parse(queryStringBody(PageWebPath.NOTIFICATIONS)) + override suspend fun getSubstitutions() = substitutionService.getSubstitutions() + override suspend fun getTeacherAbsences() = substitutionService.getTeacherAbsences() + override suspend fun getTimetablePageWithSubstitutions(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption?) = + substitutionService.fetchAndMergeSubstitutions(getTimetablePage(schoolYear, periodOption)) { getStudentProfile().className } + override suspend fun getTimetablePageWithSubstitutions() = + substitutionService.fetchAndMergeSubstitutions(getTimetablePage()) { getStudentProfile().className } suspend fun setRole(role: Role) { diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt index a5219462..e0b0b564 100644 --- a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt @@ -6,7 +6,7 @@ import io.github.tomhula.jecnaapi.data.substitution.SubstitutionStatus import io.github.tomhula.jecnaapi.data.substitution.TeacherAbsence import io.github.tomhula.jecnaapi.data.substitution.LabeledTeacherAbsences import io.github.tomhula.jecnaapi.data.timetable.TimetablePage -import io.github.tomhula.jecnaapi.web.jecna.JecnaWebClient +import io.github.tomhula.jecnaapi.WebJecnaClient import kotlinx.serialization.json.Json private val json = Json { ignoreUnknownKeys = true } @@ -15,7 +15,7 @@ private val json = Json { ignoreUnknownKeys = true } * Service for managing substitutions and teacher absences. * Handles fetching, parsing, and merging substitution data. */ -class SubstitutionService(private val webClient: JecnaWebClient) { +class SubstitutionService(private val webClient: WebJecnaClient) { /** * Fetches substitutions from the remote endpoint and deserializes them. From bc2a78d7952f3fea5558c398e05bc7f145edf35d Mon Sep 17 00:00:00 2001 From: Stevek Date: Wed, 21 Jan 2026 14:19:11 +0100 Subject: [PATCH 19/21] update readme --- README.md | 1 + README_en.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 902adcb4..fa59229e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ JecnaAPI podporuje Kotlin Multiplatform pro tyto targety: `jvm`, `android`, `was - Absence a omluvný list - Profil studenta a jeho obrázek - Učebny + - Suplování - objednávání obědů - dávání obědů do/z burzy diff --git a/README_en.md b/README_en.md index 53b2e800..94ddb9d7 100644 --- a/README_en.md +++ b/README_en.md @@ -19,6 +19,7 @@ JecnaAPI supports Kotlin Multiplatform for these targets: `jvm`, `android`, `was - Absence and excuse sheet - Student profile and profile picture - Classrooms + - Substitutions - Lunch ordering - Putting lunches to/from exchange (market) From cb72034438bddf8c89a89b0c9b8152d825b9adac Mon Sep 17 00:00:00 2001 From: Stevek Date: Wed, 21 Jan 2026 14:26:38 +0100 Subject: [PATCH 20/21] formatting --- .../jecnaapi/data/substitution/Substitution.kt | 13 +++++++------ .../tomhula/jecnaapi/data/timetable/Timetable.kt | 8 +++++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt index 0da72c85..7a75b142 100644 --- a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt @@ -26,12 +26,13 @@ data class SubstitutionResponse( val absencesByDay: List> by lazy { schedule.map { dayEntry -> val rawAbsences = dayEntry["ABSENCE"] - if (rawAbsences == null || rawAbsences is JsonNull) emptyList() else - when (rawAbsences) - { - is JsonArray -> rawAbsences.mapNotNull { it.toTeacherAbsenceOrNull() } - else -> emptyList() - } + if (rawAbsences == null || rawAbsences is JsonNull) emptyList() + else + when (rawAbsences) + { + is JsonArray -> rawAbsences.mapNotNull { it.toTeacherAbsenceOrNull() } + else -> emptyList() + } } } diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/Timetable.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/Timetable.kt index af53d464..ef09f26f 100644 --- a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/Timetable.kt +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/Timetable.kt @@ -59,7 +59,8 @@ class Timetable internal constructor( */ fun isEmpty() = timetable.isEmpty() - private fun nowLocalTime(): LocalTime { + private fun nowLocalTime(): LocalTime + { return LocalTime(12, 0) } @@ -69,7 +70,7 @@ class Timetable internal constructor( /** Returns index of the [LessonPeriod] at the given [time], or `null` if there is not any. */ fun getIndexOfLessonPeriod(time: LocalTime) = lessonPeriods.indexOfFirst { time in it }.let { if (it != -1) it else null } - + /** Returns the [LessonPeriod] at the given [time], or `null` if there is not any. */ fun getLessonPeriod(time: LocalTime) = getIndexOfLessonPeriod(time)?.let { lessonPeriods[it] } @@ -151,7 +152,8 @@ class Timetable internal constructor( else substitutionText - lessonSpots[spotIndex] = LessonSpot(originalSpot.lessons, originalSpot.periodSpan, newSubstitution) + lessonSpots[spotIndex] = + LessonSpot(originalSpot.lessons, originalSpot.periodSpan, newSubstitution) } } } From 5459033b3ef7ce7b08e40df4ca56e712cb015258 Mon Sep 17 00:00:00 2001 From: Stevek Date: Wed, 21 Jan 2026 16:58:59 +0100 Subject: [PATCH 21/21] fix nasty bugs --- build.gradle.kts | 1 - .../jecnaapi/java/JecnaClientJavaWrapper.kt | 17 ++++------------- .../github/tomhula/jecnaapi/WebJecnaClient.kt | 1 + 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index ebbab0f1..cb3f90cc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,7 +45,6 @@ kotlin { } commonTest.dependencies { implementation(kotlin("test")) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") } jvmMain.dependencies { runtimeOnly(libs.ktor.client.engine.java) diff --git a/jecnaapi-java/src/jvmMain/kotlin/io/github/tomhula/jecnaapi/java/JecnaClientJavaWrapper.kt b/jecnaapi-java/src/jvmMain/kotlin/io/github/tomhula/jecnaapi/java/JecnaClientJavaWrapper.kt index 136b126e..d3fbb8ab 100644 --- a/jecnaapi-java/src/jvmMain/kotlin/io/github/tomhula/jecnaapi/java/JecnaClientJavaWrapper.kt +++ b/jecnaapi-java/src/jvmMain/kotlin/io/github/tomhula/jecnaapi/java/JecnaClientJavaWrapper.kt @@ -40,24 +40,15 @@ class JecnaClientJavaWrapper(autoLogin: Boolean = false) fun getGradesPage(schoolYear: SchoolYear, schoolYearHalf: SchoolYearHalf) = GlobalScope.future { wrappedClient.getGradesPage(schoolYear, schoolYearHalf) } fun getTimetablePage() = GlobalScope.future { wrappedClient.getTimetablePage() } - - fun getTimetablePage(withSubstitution: Boolean) = GlobalScope.future { wrappedClient.getTimetablePage(withSubstitution) } - + fun getTimetablePageWithSubstitutions() = GlobalScope.future { wrappedClient.getTimetablePageWithSubstitutions() } fun getTimetablePage(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null) = GlobalScope.future { wrappedClient.getTimetablePage(schoolYear, periodOption) } - - fun getTimetablePage(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null, withSubstitution: Boolean) = - GlobalScope.future { wrappedClient.getTimetablePage(schoolYear, periodOption, withSubstitution) } - + fun getTimetablePageWithSubstitutions(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null) = + GlobalScope.future { wrappedClient.getTimetablePageWithSubstitutions(schoolYear, periodOption) } fun getSubstitutions() = GlobalScope.future { wrappedClient.getSubstitutions() } - fun getTeacherAbsences() = GlobalScope.future { wrappedClient.getTeacherAbsences() } - fun getAttendancePage() = GlobalScope.future { wrappedClient.getAttendancesPage() } - - fun getAttendancePage(schoolYear: SchoolYear, month: Int) = - GlobalScope.future { wrappedClient.getAttendancesPage(schoolYear, month) } - + fun getAttendancePage(schoolYear: SchoolYear, month: Month) = GlobalScope.future { wrappedClient.getAttendancesPage(schoolYear, month) } fun getAbsencesPage() = GlobalScope.future { wrappedClient.getAbsencesPage() } diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/WebJecnaClient.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/WebJecnaClient.kt index 55faf45a..494dface 100644 --- a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/WebJecnaClient.kt +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/WebJecnaClient.kt @@ -5,6 +5,7 @@ import com.fleeksoft.ksoup.nodes.Document import io.ktor.client.statement.* import io.ktor.http.* import io.github.tomhula.jecnaapi.data.notification.NotificationReference +import io.github.tomhula.jecnaapi.data.timetable.TimetablePage import io.github.tomhula.jecnaapi.parser.parsers.* import io.github.tomhula.jecnaapi.service.SubstitutionService import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder