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