- 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>
153 lines
6.5 KiB
Swift
153 lines
6.5 KiB
Swift
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) })
|
||
}
|