From caf3debb3f6b8c61d7394406d49bf55f18a822b7 Mon Sep 17 00:00:00 2001 From: Agzamus Date: Fri, 11 Sep 2026 01:55:52 +0300 Subject: [PATCH] feat: 2-week select --- .../3.json | 427 ++++++++++++++++++ .../agzam/vyatsu/data/local/AppDatabase.kt | 3 +- .../java/agzam/vyatsu/data/local/GroupDao.kt | 9 + .../agzam/vyatsu/data/local/GroupEntity.kt | 4 - .../vyatsu/data/local/GroupPeriodEntity.kt | 28 ++ .../data/repository/GroupRepositoryImpl.kt | 43 +- .../data/repository/ScheduleRepositoryImpl.kt | 77 ++-- 7 files changed, 528 insertions(+), 63 deletions(-) create mode 100644 app/schemas/agzam.vyatsu.data.local.AppDatabase/3.json create mode 100644 app/src/main/java/agzam/vyatsu/data/local/GroupPeriodEntity.kt diff --git a/app/schemas/agzam.vyatsu.data.local.AppDatabase/3.json b/app/schemas/agzam.vyatsu.data.local.AppDatabase/3.json new file mode 100644 index 0000000..c244285 --- /dev/null +++ b/app/schemas/agzam.vyatsu.data.local.AppDatabase/3.json @@ -0,0 +1,427 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "3fd5eda9ac5e29fc96371ecf246e437c", + "entities": [ + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "faculty", + "columnName": "faculty", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "group_periods", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "season", + "columnName": "season", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weekEnd", + "columnName": "weekEnd", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_group_periods_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_group_periods_groupId` ON `${TABLE_NAME}` (`groupId`)" + } + ], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "schedules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "season", + "columnName": "season", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weekEnd", + "columnName": "weekEnd", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_schedules_groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_schedules_groupId` ON `${TABLE_NAME}` (`groupId`)" + }, + { + "name": "index_schedules_groupId_season_weekStart", + "unique": true, + "columnNames": [ + "groupId", + "season", + "weekStart" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_schedules_groupId_season_weekStart` ON `${TABLE_NAME}` (`groupId`, `season`, `weekStart`)" + } + ], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "groupId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "days", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduleId", + "columnName": "scheduleId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_days_scheduleId", + "unique": false, + "columnNames": [ + "scheduleId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)" + }, + { + "name": "index_days_date", + "unique": false, + "columnNames": [ + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_days_date` ON `${TABLE_NAME}` (`date`)" + } + ], + "foreignKeys": [ + { + "table": "schedules", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "scheduleId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "lessons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT, FOREIGN KEY(`dayId`) REFERENCES `days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dayId", + "columnName": "dayId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "slotIndex", + "columnName": "slotIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lessonIndex", + "columnName": "lessonIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "room", + "columnName": "room", + "affinity": "TEXT" + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT" + }, + { + "fieldPath": "subgroup", + "columnName": "subgroup", + "affinity": "TEXT" + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "teacher", + "columnName": "teacher", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_lessons_dayId", + "unique": false, + "columnNames": [ + "dayId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)" + }, + { + "name": "index_lessons_date", + "unique": false, + "columnNames": [ + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_date` ON `${TABLE_NAME}` (`date`)" + }, + { + "name": "index_lessons_subject", + "unique": false, + "columnNames": [ + "subject" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_subject` ON `${TABLE_NAME}` (`subject`)" + }, + { + "name": "index_lessons_room", + "unique": false, + "columnNames": [ + "room" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_room` ON `${TABLE_NAME}` (`room`)" + } + ], + "foreignKeys": [ + { + "table": "days", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dayId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "preferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3fd5eda9ac5e29fc96371ecf246e437c')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/data/local/AppDatabase.kt b/app/src/main/java/agzam/vyatsu/data/local/AppDatabase.kt index 10d5371..76a8b7e 100644 --- a/app/src/main/java/agzam/vyatsu/data/local/AppDatabase.kt +++ b/app/src/main/java/agzam/vyatsu/data/local/AppDatabase.kt @@ -7,12 +7,13 @@ import androidx.room.TypeConverters @Database( entities = [ GroupEntity::class, + GroupPeriodEntity::class, ScheduleEntity::class, DayEntity::class, LessonEntity::class, PreferenceEntity::class ], - version = 2, + version = 3, exportSchema = true ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/agzam/vyatsu/data/local/GroupDao.kt b/app/src/main/java/agzam/vyatsu/data/local/GroupDao.kt index b476194..6220bff 100644 --- a/app/src/main/java/agzam/vyatsu/data/local/GroupDao.kt +++ b/app/src/main/java/agzam/vyatsu/data/local/GroupDao.kt @@ -25,4 +25,13 @@ interface GroupDao { @Query("SELECT MAX(lastSyncedAt) FROM groups") suspend fun maxSyncedAt(): Long? + + @Query("SELECT * FROM group_periods WHERE groupId = :groupId AND weekEnd >= :fromEpochDay ORDER BY weekStart") + suspend fun getCurrentAndFuturePeriods(groupId: String, fromEpochDay: Long): List + + @Query("DELETE FROM group_periods") + suspend fun deleteAllPeriods() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertPeriods(periods: List) } diff --git a/app/src/main/java/agzam/vyatsu/data/local/GroupEntity.kt b/app/src/main/java/agzam/vyatsu/data/local/GroupEntity.kt index 3c3f2a5..a096692 100644 --- a/app/src/main/java/agzam/vyatsu/data/local/GroupEntity.kt +++ b/app/src/main/java/agzam/vyatsu/data/local/GroupEntity.kt @@ -8,9 +8,5 @@ data class GroupEntity( @PrimaryKey val id: String, val name: String, val faculty: String, - val autumnFirst: Long?, - val autumnSecond: Long?, - val springFirst: Long?, - val springSecond: Long?, val lastSyncedAt: Long ) diff --git a/app/src/main/java/agzam/vyatsu/data/local/GroupPeriodEntity.kt b/app/src/main/java/agzam/vyatsu/data/local/GroupPeriodEntity.kt new file mode 100644 index 0000000..79d22eb --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/data/local/GroupPeriodEntity.kt @@ -0,0 +1,28 @@ +package agzam.vyatsu.data.local + +import agzam.vyatsu.domain.model.Season +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +// Все опубликованные на сайте 2-недельные периоды группы, включая ещё не начавшиеся. +@Entity( + tableName = "group_periods", + foreignKeys = [ + ForeignKey( + entity = GroupEntity::class, + parentColumns = ["id"], + childColumns = ["groupId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("groupId")] +) +data class GroupPeriodEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val groupId: String, + val season: Season, + val weekStart: Long, + val weekEnd: Long +) \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/data/repository/GroupRepositoryImpl.kt b/app/src/main/java/agzam/vyatsu/data/repository/GroupRepositoryImpl.kt index 399b778..41032b7 100644 --- a/app/src/main/java/agzam/vyatsu/data/repository/GroupRepositoryImpl.kt +++ b/app/src/main/java/agzam/vyatsu/data/repository/GroupRepositoryImpl.kt @@ -2,12 +2,12 @@ package agzam.vyatsu.data.repository import agzam.vyatsu.data.local.GroupDao import agzam.vyatsu.data.local.GroupEntity +import agzam.vyatsu.data.local.GroupPeriodEntity import agzam.vyatsu.data.remote.GroupsParser import agzam.vyatsu.data.remote.VyatsuHttpClient import agzam.vyatsu.data.remote.VyatsuUrls import agzam.vyatsu.domain.ScheduleDates import agzam.vyatsu.domain.model.Group -import agzam.vyatsu.domain.model.Season import agzam.vyatsu.domain.repository.GroupRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -45,26 +45,33 @@ class GroupRepositoryImpl( val parsed = withContext(Dispatchers.IO) { VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream) } - val entities = parsed.groups.map { group -> - val range = parsed.dateRanges[group.id] - val autumn = range?.get(Season.AUTUMN)?.lastOrNull() - val spring = range?.get(Season.SPRING)?.lastOrNull() - GroupEntity( - id = group.id, - name = group.name, - faculty = group.faculty, - autumnFirst = autumn?.first?.let(::epochDay), - autumnSecond = autumn?.second?.let(::epochDay), - springFirst = spring?.first?.let(::epochDay), - springSecond = spring?.second?.let(::epochDay), - lastSyncedAt = now() - ) + val now = now() + groupDao.insertAll( + parsed.groups.map { group -> + GroupEntity( + id = group.id, + name = group.name, + faculty = group.faculty, + lastSyncedAt = now + ) + } + ) + val periods = parsed.groups.flatMap { group -> + parsed.dateRanges[group.id].orEmpty().flatMap { (season, ranges) -> + ranges.map { range -> + GroupPeriodEntity( + groupId = group.id, + season = season, + weekStart = ScheduleDates.asDate(range.first).toEpochDay(), + weekEnd = ScheduleDates.asDate(range.second).toEpochDay() + ) + } + } } - groupDao.insertAll(entities) + groupDao.deleteAllPeriods() + groupDao.insertPeriods(periods) } - private fun epochDay(dateStr: String): Long = ScheduleDates.asDate(dateStr).toEpochDay() - private fun now(): Long = System.currentTimeMillis() private fun GroupEntity.toDomain(): Group = Group( diff --git a/app/src/main/java/agzam/vyatsu/data/repository/ScheduleRepositoryImpl.kt b/app/src/main/java/agzam/vyatsu/data/repository/ScheduleRepositoryImpl.kt index d34cc06..f854c67 100644 --- a/app/src/main/java/agzam/vyatsu/data/repository/ScheduleRepositoryImpl.kt +++ b/app/src/main/java/agzam/vyatsu/data/repository/ScheduleRepositoryImpl.kt @@ -1,6 +1,7 @@ package agzam.vyatsu.data.repository import agzam.vyatsu.data.local.DayEntity +import agzam.vyatsu.data.local.GroupPeriodEntity import agzam.vyatsu.data.local.LessonEntity import agzam.vyatsu.data.local.ScheduleDao import agzam.vyatsu.data.local.ScheduleEntity @@ -51,15 +52,38 @@ class ScheduleRepositoryImpl( Log.w(TAG, "sync: group $groupId not found in DB") return null } - val season = ScheduleDates.currentSeason() - val range = dateRangeFor(group, season) ?: run { - Log.w(TAG, "sync: no date range for season $season (group ${group.id})") + val today = ScheduleDates.moscowToday().toEpochDay() + val periods = groupDao.getCurrentAndFuturePeriods(groupId, today) + if (periods.isEmpty()) { + Log.w(TAG, "sync: no current/future periods for group ${group.id}") return null } - Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]") - return try { - val pdfUrl = VyatsuUrls.groupPdfUrl(groupId, season, range) + for (period in periods) { + syncPeriod(group, period) + } + + scheduleDao.trim(groupId, MAX_PERIODS) + + return getSchedule(groupId) + } + + private suspend fun syncPeriod(group: GroupEntity, period: GroupPeriodEntity) { + val season = period.season + val range = DateRange( + LocalDate.ofEpochDay(period.weekStart).format(RANGE_FMT), + LocalDate.ofEpochDay(period.weekEnd).format(RANGE_FMT) + ) + val existing = scheduleDao.getSchedules(group.id) + .firstOrNull { it.season == season && it.weekStart == period.weekStart } + val isCurrent = ScheduleDates.moscowToday().toEpochDay() in period.weekStart..period.weekEnd + if (existing != null && !isCurrent && now() - existing.lastSyncedAt < STALE_WINDOW_MS) { + Log.d(TAG, "sync: period $range up to date, skip") + return + } + + try { + val pdfUrl = VyatsuUrls.groupPdfUrl(group.id, season, range) Log.d(TAG, "sync: downloading $pdfUrl") val bytes = VyatsuHttpClient.getBytes(pdfUrl) Log.d(TAG, "sync: downloaded ${bytes.size} bytes") @@ -67,29 +91,19 @@ class ScheduleRepositoryImpl( val days = PdfScheduleParser.parse(bytes) Log.d(TAG, "sync: parsed ${days.size} days") - val weekStart = ScheduleDates.asDate(range.first).toEpochDay() - val weekEnd = ScheduleDates.asDate(range.second).toEpochDay() - - val existing = scheduleDao.getSchedules(groupId) - .firstOrNull { it.season == season && it.weekStart == weekStart } - val scheduleId = if (existing != null) existing.id - else scheduleDao.insertSchedule( + val scheduleId = existing?.id ?: scheduleDao.insertSchedule( ScheduleEntity( - groupId = groupId, + groupId = group.id, season = season, - weekStart = weekStart, - weekEnd = weekEnd, + weekStart = period.weekStart, + weekEnd = period.weekEnd, lastSyncedAt = now() ) ) replaceDays(scheduleId, days) - - scheduleDao.trim(groupId, MAX_PERIODS) - - getSchedule(groupId) + Log.d(TAG, "sync: period $range synced") } catch (e: Throwable) { - Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e) - null + Log.e(TAG, "sync: failed for ${group.id} period $range (${e::class.simpleName}: ${e.message})", e) } } @@ -187,30 +201,13 @@ class ScheduleRepositoryImpl( teacher = teacher ) - private fun dateRangeFor(group: GroupEntity, season: Season): DateRange? { - return when (season) { - Season.AUTUMN -> if (group.autumnFirst != null && group.autumnSecond != null) { - DateRange( - LocalDate.ofEpochDay(group.autumnFirst).format(RANGE_FMT), - LocalDate.ofEpochDay(group.autumnSecond).format(RANGE_FMT) - ) - } else null - Season.SPRING -> if (group.springFirst != null && group.springSecond != null) { - DateRange( - LocalDate.ofEpochDay(group.springFirst).format(RANGE_FMT), - LocalDate.ofEpochDay(group.springSecond).format(RANGE_FMT) - ) - } else null - } - } - 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 MAX_PERIODS = 3 + private const val MAX_PERIODS = 24 private const val TAG = "ScheduleRepo" private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy") }