diff --git a/README.md b/README.md index 2e02e1b1..71a57f7e 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) 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")) 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 11406476..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,11 +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(schoolYear: SchoolYear, periodId: Int? = null) = - GlobalScope.future { wrappedClient.getTimetablePage(schoolYear, periodId) } + fun getTimetablePageWithSubstitutions() = GlobalScope.future { wrappedClient.getTimetablePageWithSubstitutions() } fun getTimetablePage(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null) = GlobalScope.future { wrappedClient.getTimetablePage(schoolYear, periodOption) } + 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: Month) = GlobalScope.future { wrappedClient.getAttendancesPage(schoolYear, month) } fun getAbsencesPage() = GlobalScope.future { wrappedClient.getAbsencesPage() } diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt index 6b17a914..9329917e 100644 --- a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/JecnaClient.kt @@ -13,6 +13,8 @@ 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.substitution.LabeledTeacherAbsences +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 @@ -48,6 +50,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 816953ff..494dface 100644 --- a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/WebJecnaClient.kt +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/WebJecnaClient.kt @@ -5,7 +5,9 @@ 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 import io.github.tomhula.jecnaapi.util.JecnaPeriodEncoder.jecnaEncode import io.github.tomhula.jecnaapi.util.SchoolYear @@ -80,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 @@ -158,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/data/substitution/AbsenceHours.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt new file mode 100644 index 00000000..88d02115 --- /dev/null +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/AbsenceHours.kt @@ -0,0 +1,15 @@ +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? = null +) 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 new file mode 100644 index 00000000..7a75b142 --- /dev/null +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/Substitution.kt @@ -0,0 +1,107 @@ +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.JsonPrimitive +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +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() + } + } + } + + /** + * 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 { + // 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 = dateLabel, + 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 +) + +/** + * Details about a teacher's absence. + */ +private fun JsonElement.toTeacherAbsenceOrNull(): TeacherAbsence? +{ + if (this !is JsonObject) 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 = when + { + 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( + teacher = teacher, + teacherCode = teacherCode, + type = type, + hours = hours + ) +} diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionProp.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionProp.kt new file mode 100644 index 00000000..263c3a8c --- /dev/null +++ b/src/commonMain/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/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/SubstitutionStatus.kt new file mode 100644 index 00000000..7f0ce9f2 --- /dev/null +++ b/src/commonMain/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, + val message: String? = null //when down +) diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt new file mode 100644 index 00000000..7d3eb7ef --- /dev/null +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/substitution/TeacherAbsence.kt @@ -0,0 +1,22 @@ +package io.github.tomhula.jecnaapi.data.substitution + +import kotlinx.serialization.Serializable + +@Serializable +data class TeacherAbsence( + val teacher: String?, + val teacherCode: String?, + /** + * Absence type, e.g. wholeDay/single/range/exkurze/invalid. + */ + val type: String, + val hours: AbsenceHours?, + /** + * 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 +) diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/LessonSpot.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/LessonSpot.kt index 321163ea..610dc29b 100644 --- a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/LessonSpot.kt +++ b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/LessonSpot.kt @@ -12,11 +12,12 @@ import kotlin.jvm.JvmStatic * 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 @@ -53,6 +54,7 @@ class LessonSpot(val lessons: List, val periodSpan: Int = 1) : Iterable< return "LessonSpot{" + "lessons=" + lessons + ", periodSpan=" + periodSpan + + ", substitution=" + substitution + '}' } 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 811992a5..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 @@ -2,9 +2,14 @@ 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.next import io.github.tomhula.jecnaapi.util.setAll +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone @@ -16,7 +21,7 @@ import kotlin.time.ExperimentalTime import kotlin.time.Instant @Serializable(with = TimetableSerializer::class) -class Timetable private constructor( +class Timetable internal constructor( lessonPeriods: List, private val timetable: Map> ) @@ -54,7 +59,8 @@ class Timetable private constructor( */ fun isEmpty() = timetable.isEmpty() - private fun nowLocalTime(): LocalTime { + private fun nowLocalTime(): LocalTime + { return LocalTime(12, 0) } @@ -65,6 +71,7 @@ class Timetable private constructor( 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] } @@ -83,15 +90,79 @@ class Timetable private constructor( return lessonPeriods.indices.filter { minsUntilStartOf(it) > 0 }.minByOrNull { minsUntilStartOf(it) } } + /** Returns the index of next [LessonPeriod] from [LocalTime.now], or `null` if there is no next [LessonPeriod]. */ + 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(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] /** 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/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/data/timetable/TimetablePage.kt index f8016945..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,6 +1,7 @@ package io.github.tomhula.jecnaapi.data.timetable import kotlinx.serialization.Serializable +import io.github.tomhula.jecnaapi.data.substitution.SubstitutionResponse import io.github.tomhula.jecnaapi.util.SchoolYear import io.github.tomhula.jecnaapi.util.setAll import kotlinx.datetime.LocalDate @@ -13,13 +14,22 @@ import kotlin.jvm.JvmStatic */ @ConsistentCopyVisibility @Serializable -data class TimetablePage private constructor( +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), + substitutionMessage = substitutionResponse.status.message + ) + } companion object { diff --git a/src/commonMain/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt b/src/commonMain/kotlin/io/github/tomhula/jecnaapi/service/SubstitutionService.kt new file mode 100644 index 00000000..e0b0b564 --- /dev/null +++ b/src/commonMain/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.WebJecnaClient +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: WebJecnaClient) { + + /** + * 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" + } +} +