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() 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: ", ")), ("Город", a.lesson.place ?? "", b.lesson.place ?? ""), ] 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) }) }