Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6d82a82
Add basic fetching
Stevekk11 Dec 16, 2025
ecb6f25
Split each data class into its own file
Stevekk11 Dec 16, 2025
e037d6d
Add bool for choice
Stevekk11 Dec 16, 2025
3179c6b
Add teacher absence parsing
Stevekk11 Dec 16, 2025
b8a1093
Fix typo
Stevekk11 Dec 16, 2025
259213c
Json fix
Stevekk11 Dec 16, 2025
1129253
getTeacherAbsences() now always returns labeled absences for ease of use
Stevekk11 Dec 16, 2025
c3acc2a
add overloads for backward compatibility
Stevekk11 Dec 16, 2025
bef84c9
Handle endpoint down
Stevekk11 Dec 18, 2025
6643f17
fix
Stevekk11 Dec 18, 2025
543ef21
Improvements
Stevekk11 Dec 18, 2025
b2e1cc9
Merge branch 'tomhula:main' into feature/substitutions
Stevekk11 Dec 23, 2025
73fb9ba
Make code more modular and move to a separate class
Stevekk11 Dec 27, 2025
81abc2a
Add Java wrapper
Stevekk11 Dec 27, 2025
be04b20
Merge remote-tracking branch 'upstream/main' into feature/substitutions
Stevekk11 Jan 20, 2026
74ae8e1
put on commonMain
Stevekk11 Jan 20, 2026
b8551f6
add json
Stevekk11 Jan 20, 2026
a11eb24
Merge branch 'tomhula:main' into feature/substitutions
Stevekk11 Jan 20, 2026
f037c32
Merge remote-tracking branch 'origin/feature/substitutions' into feat…
Stevekk11 Jan 20, 2026
f7092cb
fix
Stevekk11 Jan 20, 2026
3914e56
add runTest
Stevekk11 Jan 20, 2026
9778559
Merge branch 'tomhula:main' into feature/substitutions
Stevekk11 Jan 20, 2026
430fbca
Merge branch 'tomhula:main' into feature/substitutions
Stevekk11 Jan 20, 2026
076816b
Merge branch 'main' into feature/substitutions
Stevekk11 Jan 21, 2026
af890b6
Merge branch 'main' into feature/substitutions
Stevekk11 Jan 21, 2026
4c9ab0d
add back substitutions into the new interface.
Stevekk11 Jan 21, 2026
031f650
Merge remote-tracking branch 'origin/feature/substitutions' into feat…
Stevekk11 Jan 21, 2026
bc2a78d
update readme
Stevekk11 Jan 21, 2026
cb72034
formatting
Stevekk11 Jan 21, 2026
c53e903
Merge branch 'main' into feature/substitutions
Stevekk11 Jan 21, 2026
5459033
fix nasty bugs
Stevekk11 Jan 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,6 +50,10 @@ interface JecnaClient
suspend fun getStudentProfile(username: String): Student
suspend fun getNotifications(): List<NotificationReference>
suspend fun getNotification(notification: NotificationReference): Notification
suspend fun getSubstitutions(): SubstitutionResponse
suspend fun getTeacherAbsences(): List<LabeledTeacherAbsences>
suspend fun getTimetablePageWithSubstitutions(schoolYear: SchoolYear, periodOption: TimetablePage.PeriodOption? = null): TimetablePage
suspend fun getTimetablePageWithSubstitutions(): TimetablePage

companion object
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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<Map<String, JsonElement>>,
val props: List<SubstitutionProp>,
val status: SubstitutionStatus
)
{
/**
* Returns absences for each day (same index as in [schedule]/[props]).
*/
val absencesByDay: List<List<TeacherAbsence>> 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<LabeledTeacherAbsences> 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<TeacherAbsence>
)

/**
* 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
)
}
Original file line number Diff line number Diff line change
@@ -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,
)




Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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<Lesson>, val periodSpan: Int = 1) : Iterable<Lesson>
class LessonSpot(val lessons: List<Lesson>, val periodSpan: Int = 1, val substitution: String? = null) : Iterable<Lesson>
{
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
Expand Down Expand Up @@ -53,6 +54,7 @@ class LessonSpot(val lessons: List<Lesson>, val periodSpan: Int = 1) : Iterable<
return "LessonSpot{" +
"lessons=" + lessons +
", periodSpan=" + periodSpan +
", substitution=" + substitution +
'}'
}

Expand Down
Loading