Files
psu-schedule/ios/Shared/Changes.swift
Zakhar Timoshenko 10c85e3bb9 feat: read "Занятия в Полоцке" as a day's town, show it in all clients
- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:26:54 +03:00

153 lines
6.5 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: ", ")),
("Город", 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) })
}