Files
psu-schedule/ios/Shared/Changes.swift
Zakhar Timoshenko f8984e72d9 feat: implement changes tracking for university documents
- Added a new data structure `ScheduleChanges` to represent changes when a university document is republished.
- Implemented methods to read and write changes to a local JSON file.
- Enhanced the `Repository` class to detect changes between the current and fresh timetable versions.
- Updated the `MainViewModel` to expose changes to the UI and handle marking changes as seen.
- Created UI components for displaying changes in both Home and Settings screens.
- Added support for changes in iOS views, including a new `ChangesCard` and `ChangesSheet`.
- Implemented logic to compare timetable versions and identify added, removed, and changed lessons.
2026-10-19 23:57:10 +03:00

152 lines
6.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
// What changed for the group when the university republished its document, as the PWA reports it
// (shared/lessons.js diffSchedules) and the Android client's Changes.kt: the new timetable is
// compared with the copy on the device.
/// A Word row of the timetable: a weekday's lesson, or a lesson on its own date.
struct ChangedLesson: Codable, Hashable {
var day: Int
var date: String?
var lesson: Lesson
}
struct FieldChange: Codable, Hashable {
var label: String
var from: String
var to: String
}
struct LessonChange: Codable, Hashable {
var before: ChangedLesson
var after: ChangedLesson
var fields: [FieldChange]
}
struct ScheduleChanges: Codable, Hashable {
var document: String
var group: String
var name: String
/// When the change was found.
var at: Date
/// Dates of the two versions of the file, as the server sends them.
var from: String?
var to: String?
var added: [ChangedLesson] = []
var removed: [ChangedLesson] = []
var changed: [LessonChange] = []
/// The card on the overview was opened or closed.
var seen = false
var count: Int { added.count + removed.count + changed.count }
/**
* Compares a newer version of the same document with the copy on the device: only a
* republished file counts, not a new parser or another document of the group.
*/
static func between(_ previous: Timetable?, _ fresh: Timetable, now: Date = Date()) -> ScheduleChanges? {
guard let previous, previous.document.id == fresh.document.id, previous.group == fresh.group,
let was = previous.document.modified, let then = fresh.document.modified, was != then
else { return nil }
let (added, removed, changed) = diff(previous, fresh)
let record = ScheduleChanges(
document: fresh.document.id, group: fresh.group, name: fresh.document.name, at: now,
from: was, to: then, added: added, removed: removed, changed: changed
)
return record.count > 0 ? record : nil
}
}
/// "3 изменения".
func changesLabel(_ n: Int) -> String { "\(n) \(plural(n, "изменение", "изменения", "изменений"))" }
/// The date of a file version as the server sends it ("Mon, 19 Oct 2026 08:00:00 GMT").
func fileDateLabel(_ value: String?) -> String? {
guard let value else { return nil }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
return f.date(from: value).map { CivilDate($0).longLabel }
}
/**
* The timetable's rows: the weekly Word rows, plus lessons printed with their own date (the
* server's key then starts with that date). Dates computed from week marks are not compared one
* by one, or one changed mark would read as a dozen changes.
*/
private func rows(_ t: Timetable) -> [ChangedLesson] {
let weekly = t.weekly.flatMap { d in d.lessons.map { ChangedLesson(day: d.day, date: nil, lesson: $0) } }
var seen = Set<String>()
let dated = t.days.flatMap { d in
d.lessons.filter { $0.key.range(of: #"^\d{4}-\d{2}-\d{2}\|"#, options: .regularExpression) != nil }
.map { ChangedLesson(day: d.weekday, date: d.date, lesson: $0) }
}.filter { seen.insert($0.lesson.key).inserted }
return weekly + dated
}
private func norm(_ s: String) -> String {
s.lowercased().split(whereSeparator: \.isWhitespace).joined(separator: " ")
}
/// Looser identity for pairing a lesson whose day, time or week mark changed.
private func looseKey(_ l: Lesson) -> String {
[norm(l.subject), l.variant, l.subgroup ?? "", norm(l.type)].joined(separator: "|")
}
private func whenLabel(_ row: ChangedLesson) -> String {
row.date.flatMap { CivilDate($0) }.map { "\($0.weekdayName), \($0.longLabel)" } ?? dayName(row.day)
}
private func fields(_ a: ChangedLesson, _ b: ChangedLesson) -> [FieldChange] {
let pairs: [(String, String, String)] = [
("День", whenLabel(a), whenLabel(b)),
("Начало", a.lesson.start, b.lesson.start),
("Окончание", a.lesson.end ?? "", b.lesson.end ?? ""),
("Недели", a.lesson.condition, b.lesson.condition),
("Вид занятия", a.lesson.type, b.lesson.type),
("Преподаватель", a.lesson.teachers.joined(separator: ", "), b.lesson.teachers.joined(separator: ", ")),
("Аудитория", a.lesson.rooms.joined(separator: ", "), b.lesson.rooms.joined(separator: ", ")),
]
let shown = { (s: String) in s.trimmed.isEmpty ? "не указано" : s }
return pairs.filter { $0.1.trimmed != $0.2.trimmed }.map { FieldChange(label: $0.0, from: shown($0.1), to: shown($0.2)) }
}
private func order(_ a: ChangedLesson, _ b: ChangedLesson) -> Bool {
((a.date ?? ""), a.day, a.lesson.start) < ((b.date ?? ""), b.day, b.lesson.start)
}
/// Added, removed and changed rows between two versions of the group's timetable.
private func diff(_ before: Timetable, _ after: Timetable) -> ([ChangedLesson], [ChangedLesson], [LessonChange]) {
var old: [String: [ChangedLesson]] = [:]
var order0: [String] = []
for row in rows(before) {
if old[row.lesson.key] == nil { order0.append(row.lesson.key) }
old[row.lesson.key, default: []].append(row)
}
var changed: [LessonChange] = []
var unmatched: [ChangedLesson] = []
for row in rows(after) {
if var list = old[row.lesson.key], !list.isEmpty {
let was = list.removeFirst()
old[row.lesson.key] = list
let f = fields(was, row)
if !f.isEmpty { changed.append(LessonChange(before: was, after: row, fields: f)) }
} else {
unmatched.append(row)
}
}
var left = order0.flatMap { old[$0] ?? [] }
// Pair the rest by subject on the same day first, then by subject alone (moved to another day).
for sameDay in [true, false] {
for row in unmatched {
guard let index = left.firstIndex(where: {
looseKey($0.lesson) == looseKey(row.lesson) && (!sameDay || ($0.date ?? "\($0.day)") == (row.date ?? "\(row.day)"))
}) else { continue }
let was = left.remove(at: index)
if let i = unmatched.firstIndex(of: row) { unmatched.remove(at: i) }
changed.append(LessonChange(before: was, after: row, fields: fields(was, row)))
}
}
return (unmatched.sorted(by: order), left.sorted(by: order), changed.sorted { order($0.after, $1.after) })
}