feat(data): add TeacherScheduleRepositoryImpl with bulk period sync
This commit is contained in:
@@ -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<TeacherSchedule?> =
|
||||
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<List<Teacher>> =
|
||||
scheduleDao.observeDistinctTeachers(period.season.name, period.weekStart.toEpochDay())
|
||||
.map { names -> names.map { name -> Teacher(name = name) } }
|
||||
|
||||
override suspend fun syncPeriod(date: LocalDate): Flow<BulkSyncProgress> = 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<Day>) {
|
||||
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<TeacherDayEntity>,
|
||||
lessonEntities: List<TeacherLessonEntity>
|
||||
): 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user