From 6c06892d9ab748eb87cb861018acf942b3f86588 Mon Sep 17 00:00:00 2001 From: Agzamus Date: Sat, 19 Sep 2026 19:12:19 +0300 Subject: [PATCH] feat(data): add TeacherScheduleRepositoryImpl with bulk period sync --- .../TeacherScheduleRepositoryImpl.kt | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 app/src/main/java/agzam/vyatsu/data/repository/TeacherScheduleRepositoryImpl.kt diff --git a/app/src/main/java/agzam/vyatsu/data/repository/TeacherScheduleRepositoryImpl.kt b/app/src/main/java/agzam/vyatsu/data/repository/TeacherScheduleRepositoryImpl.kt new file mode 100644 index 0000000..e053aa0 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/data/repository/TeacherScheduleRepositoryImpl.kt @@ -0,0 +1,228 @@ +package agzam.vyatsu.data.repository + +import agzam.vyatsu.data.local.DepartmentPeriodEntity +import agzam.vyatsu.data.local.TeacherDao +import agzam.vyatsu.data.local.TeacherDayEntity +import agzam.vyatsu.data.local.TeacherLessonEntity +import agzam.vyatsu.data.local.TeacherScheduleDao +import agzam.vyatsu.data.local.TeacherScheduleEntity +import agzam.vyatsu.data.remote.TeacherScheduleParser +import agzam.vyatsu.data.remote.VyatsuHttpClient +import agzam.vyatsu.data.remote.VyatsuUrls +import agzam.vyatsu.domain.model.BulkSyncProgress +import agzam.vyatsu.domain.model.DateRange +import agzam.vyatsu.domain.model.Day +import agzam.vyatsu.domain.model.Lesson +import agzam.vyatsu.domain.model.SchedulePeriod +import agzam.vyatsu.domain.model.Teacher +import agzam.vyatsu.domain.model.TeacherSchedule +import agzam.vyatsu.domain.model.TimeSlot +import agzam.vyatsu.domain.repository.TeacherScheduleRepository +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import java.time.LocalDate +import java.time.LocalTime + +class TeacherScheduleRepositoryImpl( + private val scheduleDao: TeacherScheduleDao, + private val teacherDao: TeacherDao +) : TeacherScheduleRepository { + + override fun observeSchedule(period: SchedulePeriod): Flow = + combine( + scheduleDao.observeDaysForPeriod(period.season.name, period.weekStart.toEpochDay()), + scheduleDao.observeLessonsForPeriod(period.season.name, period.weekStart.toEpochDay()) + ) { days, lessons -> + buildTeacherSchedule(period, days, lessons) + } + + override fun observeTeachers(period: SchedulePeriod): Flow> = + scheduleDao.observeDistinctTeachers(period.season.name, period.weekStart.toEpochDay()) + .map { names -> names.map { name -> Teacher(name = name) } } + + override suspend fun syncPeriod(date: LocalDate): Flow = flow { + val targets = withContext(Dispatchers.IO) { + teacherDao.getPeriodsForDate(date.toEpochDay()) + } + if (targets.isEmpty()) { + emit(BulkSyncProgress(0, 0, 0)) + return@flow + } + + targets.map { it.season to it.weekStart } + .distinct() + .forEach { (season, weekStart) -> + withContext(Dispatchers.IO) { + scheduleDao.deletePeriod(season.name, weekStart) + } + } + + val total = targets.size + val semaphore = Semaphore(PARALLELISM) + val jobs = coroutineScope { + targets.map { target -> + async { + semaphore.withPermit { + try { + withContext(Dispatchers.IO) { syncDepartment(target) } + true + } catch (e: Throwable) { + Log.e( + TAG, + "sync: failed for department ${target.departmentId} " + + "season ${target.season} week ${target.weekStart} " + + "(${e::class.simpleName}: ${e.message})", + e + ) + false + } + } + } + } + } + + var done = 0 + var failed = 0 + for (job in jobs) { + if (job.await()) done++ else failed++ + emit(BulkSyncProgress(total, done + failed, failed)) + } + + withContext(Dispatchers.IO) { trimToRecent(keepDistinctPeriods = 2) } + } + + override suspend fun hasData(period: SchedulePeriod): Boolean = + withContext(Dispatchers.IO) { + scheduleDao.hasSchedulesForPeriod(period.season.name, period.weekStart.toEpochDay()) + } + + override suspend fun clearAll() = withContext(Dispatchers.IO) { + scheduleDao.deleteAll() + } + + private suspend fun syncDepartment(target: DepartmentPeriodEntity) { + val season = target.season + val range = DateRange( + LocalDate.ofEpochDay(target.weekStart).format(RANGE_FMT), + LocalDate.ofEpochDay(target.weekEnd).format(RANGE_FMT) + ) + val url = VyatsuUrls.teacherPrepodUrl(target.departmentId, season, range) + val html = VyatsuHttpClient.getBytes(url).toString(Charsets.UTF_8) + val parsed = TeacherScheduleParser.parse(html) + + val scheduleId = scheduleDao.insertSchedule( + TeacherScheduleEntity( + departmentId = target.departmentId, + season = season, + weekStart = target.weekStart, + weekEnd = target.weekEnd, + lastSyncedAt = now() + ) + ) + replaceDays(scheduleId, parsed.days) + } + + private suspend fun replaceDays(scheduleId: Long, days: List) { + scheduleDao.deleteDaysForSchedule(scheduleId) + val dayEntities = days.map { + TeacherDayEntity(scheduleId = scheduleId, date = it.date.toEpochDay()) + } + val dayIds = scheduleDao.insertDays(dayEntities) + val dayIdByDate = dayEntities.zip(dayIds).associate { (day, id) -> + LocalDate.ofEpochDay(day.date) to id + } + val lessons = days.flatMap { day -> + day.intervals.flatMapIndexed { slotIndex, slot -> + slot.lessons.mapIndexedNotNull { lessonIndex, lesson -> + val teacher = lesson.teacher ?: return@mapIndexedNotNull null + TeacherLessonEntity( + dayId = dayIdByDate.getValue(day.date), + date = day.date.toEpochDay(), + slotIndex = slotIndex, + start = slot.start.toMinutesOfDay(), + end = slot.end.toMinutesOfDay(), + lessonIndex = lessonIndex, + raw = lesson.raw, + room = lesson.room, + groupName = lesson.group, + subgroup = lesson.subgroup, + subject = lesson.subject, + type = lesson.type, + teacher = teacher + ) + } + } + } + scheduleDao.insertLessons(lessons) + } + + private suspend fun trimToRecent(keepDistinctPeriods: Int) { + val schedules = scheduleDao.getSchedules() + schedules + .map { it.season to it.weekStart } + .distinct() + .sortedByDescending { it.second } + .drop(keepDistinctPeriods) + .forEach { (season, weekStart) -> + scheduleDao.deletePeriod(season.name, weekStart) + } + } + + private fun buildTeacherSchedule( + period: SchedulePeriod, + dayEntities: List, + lessonEntities: List + ): TeacherSchedule? { + if (dayEntities.isEmpty()) return null + val lessonsByDate = lessonEntities.groupBy { it.date } + val days = dayEntities + .sortedBy { it.date } + .distinctBy { it.date } + .map { day -> + val lessons = lessonsByDate[day.date].orEmpty() + .groupBy { it.slotIndex } + .toSortedMap() + .map { (_, slotLessons) -> + TimeSlot( + start = LocalTime.of(slotLessons.first().start / 60, slotLessons.first().start % 60), + end = LocalTime.of(slotLessons.first().end / 60, slotLessons.first().end % 60), + lessons = slotLessons + .sortedBy { it.lessonIndex } + .map { it.toDomain() } + ) + } + Day(date = LocalDate.ofEpochDay(day.date), intervals = lessons) + } + return TeacherSchedule(period = period, days = days) + } + + private fun TeacherLessonEntity.toDomain(): Lesson = Lesson( + raw = raw, + room = room, + group = groupName, + subgroup = subgroup, + subject = subject, + type = type, + teacher = teacher + ) + + private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute + + private fun now(): Long = System.currentTimeMillis() + + companion object { + private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L + private const val PARALLELISM = 5 + private const val TAG = "TeacherScheduleRepo" + private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy") + } +} \ No newline at end of file