- 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>
477 lines
17 KiB
Swift
477 lines
17 KiB
Swift
import Foundation
|
||
|
||
// Shapes of the server JSON (see server/timetable.js and server/directory.js), as in the
|
||
// Android client's Models.kt. Missing keys fall back to defaults, so an older or newer
|
||
// server never breaks decoding.
|
||
|
||
struct Timetable: Codable, Hashable {
|
||
var group: String
|
||
var document: DocumentRef
|
||
var period: String?
|
||
var semester: Semester?
|
||
var note: String?
|
||
var subgroups: [String] = []
|
||
var bells: [Bell] = []
|
||
var from: String
|
||
var days: [Day] = []
|
||
var weekly: [WeeklyDay] = []
|
||
var notes: [String] = []
|
||
}
|
||
|
||
struct DocumentRef: Codable, Hashable {
|
||
var id: String
|
||
var name: String
|
||
var faculty: String = ""
|
||
var course: String = ""
|
||
var modified: String?
|
||
}
|
||
|
||
struct Semester: Codable, Hashable {
|
||
var id: String
|
||
var label: String
|
||
var start: String
|
||
var end: String
|
||
}
|
||
|
||
struct Bell: Codable, Hashable {
|
||
var pair: Int
|
||
var start: String
|
||
var end: String
|
||
var halves: [[String]] = []
|
||
}
|
||
|
||
struct Week: Codable, Hashable {
|
||
var n: Int
|
||
var color: String
|
||
var white: Bool { color == "white" }
|
||
}
|
||
|
||
struct Day: Codable, Hashable {
|
||
var date: String
|
||
var weekday: Int
|
||
var week: Week?
|
||
var lessons: [Lesson] = []
|
||
var places: [DayPlace] = []
|
||
}
|
||
|
||
struct WeeklyDay: Codable, Hashable {
|
||
var day: Int
|
||
var lessons: [Lesson] = []
|
||
var places: [DayPlace] = []
|
||
}
|
||
|
||
/**
|
||
* "Занятия в Полоцке" over a day in Word: that day the group's lessons (or one subgroup's,
|
||
* when `subgroup` is set) are in another town (server/timetable.js, shared/places.js).
|
||
*/
|
||
struct DayPlace: Codable, Hashable {
|
||
var place: String
|
||
var text: String
|
||
var subgroup: String?
|
||
}
|
||
|
||
extension Array where Element == DayPlace {
|
||
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
|
||
func forSubgroup(_ subgroup: String?) -> [DayPlace] {
|
||
var seen = Set<DayPlace>()
|
||
return filter { subgroup == nil || $0.subgroup == nil || $0.subgroup == subgroup }
|
||
.map { subgroup == nil ? $0 : DayPlace(place: $0.place, text: $0.text, subgroup: nil) }
|
||
.filter { seen.insert($0).inserted }
|
||
}
|
||
}
|
||
|
||
/** A room label with the lesson's town when the document names one: "ауд. 203Г · Полоцк", or the town alone. */
|
||
func withPlace(_ room: String?, _ place: String?) -> String? {
|
||
let parts = [room, place].compactMap { $0 }.filter { !$0.isEmpty }
|
||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||
}
|
||
|
||
struct Lesson: Codable, Hashable {
|
||
var key: String
|
||
var start: String
|
||
var end: String?
|
||
var pair: Int?
|
||
var subject: String
|
||
var type: String = ""
|
||
var teachers: [String] = []
|
||
var rooms: [String] = []
|
||
var roomMoved: Bool = false
|
||
/** The town, when the cell or the day's note names one: "Полоцк". */
|
||
var place: String?
|
||
var distance: Bool = false
|
||
var condition: String = ""
|
||
var variant: String = "all"
|
||
var subgroup: String?
|
||
var groups: [String] = []
|
||
var uncertain: Bool = false
|
||
var raw: String = ""
|
||
|
||
/** A recurring lesson the student may hide: the subject with its kind, in every week. */
|
||
var hideKey: String { subject.trimmed.lowercased() + "|" + type.trimmed.lowercased() }
|
||
}
|
||
|
||
struct GroupsResponse: Codable {
|
||
var results: [GroupResult] = []
|
||
var facets: Facets = Facets()
|
||
var total: Int = 0
|
||
var year: String = ""
|
||
}
|
||
|
||
struct GroupResult: Codable, Hashable, Identifiable {
|
||
var group: String
|
||
var fileId: String
|
||
var file: String = ""
|
||
var faculty: String = ""
|
||
var course: String = ""
|
||
var form: String = ""
|
||
var period: String = ""
|
||
var id: String { fileId + "|" + group }
|
||
}
|
||
|
||
struct Facets: Codable {
|
||
var faculties: [Facet] = []
|
||
var courses: [Facet] = []
|
||
var forms: [Facet] = []
|
||
}
|
||
|
||
struct Facet: Codable, Hashable {
|
||
var name: String
|
||
var count: Int
|
||
}
|
||
|
||
struct DirectorySearch: Codable {
|
||
var teachers: [TeacherHit] = []
|
||
var rooms: [RoomHit] = []
|
||
}
|
||
|
||
struct TeacherHit: Codable, Hashable {
|
||
var name: String
|
||
var count: Int = 0
|
||
var faculties: [String] = []
|
||
}
|
||
|
||
struct RoomHit: Codable, Hashable {
|
||
var room: String
|
||
var count: Int = 0
|
||
}
|
||
|
||
struct DirectoryDay: Codable {
|
||
var date: String
|
||
var lessons: [DirectoryLesson] = []
|
||
var coverage: Coverage
|
||
}
|
||
|
||
struct DirectoryLesson: Codable, Hashable {
|
||
var start: String
|
||
var end: String?
|
||
var subject: String
|
||
var type: String = ""
|
||
var teachers: [String] = []
|
||
var rooms: [String] = []
|
||
var moved: Bool = false
|
||
var place: String?
|
||
var distance: Bool = false
|
||
var groups: [String] = []
|
||
var condition: String = ""
|
||
var uncertain: Bool = false
|
||
}
|
||
|
||
struct Coverage: Codable {
|
||
var documents: Int
|
||
var total: Int
|
||
var failed: [String] = []
|
||
var withoutSemester: [String] = []
|
||
var year: String = ""
|
||
}
|
||
|
||
struct CalendarInfo: Codable {
|
||
var bells: [Bell] = []
|
||
}
|
||
|
||
/** An exam, credit or consultation of the group, from any document of the catalog (/api/exams). */
|
||
struct Exam: Codable, Hashable {
|
||
var date: String
|
||
var start: String
|
||
var end: String?
|
||
var type: String
|
||
var subject: String
|
||
var teachers: [String] = []
|
||
var rooms: [String] = []
|
||
/** Consultations go into the list but not into the countdown. */
|
||
var counts: Bool { type != "Консультация" }
|
||
}
|
||
|
||
struct ExamsResponse: Codable {
|
||
var group: String = ""
|
||
var exams: [Exam] = []
|
||
}
|
||
|
||
/** A group the student opened; kept on the device for quick switching. */
|
||
struct SavedGroup: Codable, Hashable {
|
||
var docId: String
|
||
var group: String
|
||
var faculty: String = ""
|
||
var course: String = ""
|
||
/** "docId|group": notes, marks and choices of one group never show up in another. */
|
||
var key: String { docId + "|" + group }
|
||
}
|
||
|
||
/**
|
||
* A homework task or a plain note pinned to one lesson (subject, date, start time),
|
||
* or a general plan with an empty subject. Tasks can be ticked off; notes just remind.
|
||
*/
|
||
struct Note: Codable, Hashable, Identifiable {
|
||
var id: String = UUID().uuidString
|
||
var group: String
|
||
var subject: String
|
||
var date: String
|
||
var start: String
|
||
var text: String
|
||
var task: Bool = true
|
||
var done: Bool = false
|
||
var created: Double = Date().timeIntervalSince1970 * 1000
|
||
|
||
/** A plan not pinned to any lesson: just a day. */
|
||
var general: Bool { subject.trimmed.isEmpty }
|
||
}
|
||
|
||
/** Whether the student was at one lesson. */
|
||
struct Mark: Codable, Hashable {
|
||
var group: String
|
||
var subject: String
|
||
var date: String
|
||
var start: String
|
||
var present: Bool
|
||
var id: String { [group, date, start, subject.trimmed.lowercased()].joined(separator: "|") }
|
||
}
|
||
|
||
extension String {
|
||
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
|
||
}
|
||
|
||
// MARK: - Lenient decoding
|
||
|
||
/// Keys may be missing in older answers or caches: every optional field falls back to its default.
|
||
extension Timetable {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
group = try c.decode(String.self, forKey: .group)
|
||
document = try c.decode(DocumentRef.self, forKey: .document)
|
||
period = try c.decodeIfPresent(String.self, forKey: .period)
|
||
semester = try c.decodeIfPresent(Semester.self, forKey: .semester)
|
||
note = try c.decodeIfPresent(String.self, forKey: .note)
|
||
subgroups = try c.decodeIfPresent([String].self, forKey: .subgroups) ?? []
|
||
bells = try c.decodeIfPresent([Bell].self, forKey: .bells) ?? []
|
||
from = try c.decodeIfPresent(String.self, forKey: .from) ?? ""
|
||
days = try c.decodeIfPresent([Day].self, forKey: .days) ?? []
|
||
weekly = try c.decodeIfPresent([WeeklyDay].self, forKey: .weekly) ?? []
|
||
notes = try c.decodeIfPresent([String].self, forKey: .notes) ?? []
|
||
}
|
||
}
|
||
|
||
extension DocumentRef {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
id = try c.decode(String.self, forKey: .id)
|
||
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||
faculty = try c.decodeIfPresent(String.self, forKey: .faculty) ?? ""
|
||
course = try c.decodeIfPresent(String.self, forKey: .course) ?? ""
|
||
modified = try c.decodeIfPresent(String.self, forKey: .modified)
|
||
}
|
||
}
|
||
|
||
extension Bell {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
pair = try c.decode(Int.self, forKey: .pair)
|
||
start = try c.decode(String.self, forKey: .start)
|
||
end = try c.decode(String.self, forKey: .end)
|
||
halves = try c.decodeIfPresent([[String]].self, forKey: .halves) ?? []
|
||
}
|
||
}
|
||
|
||
extension Day {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
date = try c.decode(String.self, forKey: .date)
|
||
weekday = try c.decodeIfPresent(Int.self, forKey: .weekday) ?? 0
|
||
week = try c.decodeIfPresent(Week.self, forKey: .week)
|
||
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
|
||
places = try c.decodeIfPresent([DayPlace].self, forKey: .places) ?? []
|
||
}
|
||
}
|
||
|
||
extension WeeklyDay {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
day = try c.decode(Int.self, forKey: .day)
|
||
lessons = try c.decodeIfPresent([Lesson].self, forKey: .lessons) ?? []
|
||
places = try c.decodeIfPresent([DayPlace].self, forKey: .places) ?? []
|
||
}
|
||
}
|
||
|
||
extension DayPlace {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
place = try c.decodeIfPresent(String.self, forKey: .place) ?? ""
|
||
text = try c.decodeIfPresent(String.self, forKey: .text) ?? ""
|
||
subgroup = try c.decodeIfPresent(String.self, forKey: .subgroup)
|
||
}
|
||
}
|
||
|
||
extension Lesson {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
key = try c.decodeIfPresent(String.self, forKey: .key) ?? UUID().uuidString
|
||
start = try c.decode(String.self, forKey: .start)
|
||
end = try c.decodeIfPresent(String.self, forKey: .end)
|
||
pair = try c.decodeIfPresent(Int.self, forKey: .pair)
|
||
subject = try c.decode(String.self, forKey: .subject)
|
||
type = try c.decodeIfPresent(String.self, forKey: .type) ?? ""
|
||
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
|
||
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
|
||
roomMoved = try c.decodeIfPresent(Bool.self, forKey: .roomMoved) ?? false
|
||
place = try c.decodeIfPresent(String.self, forKey: .place)
|
||
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
|
||
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""
|
||
variant = try c.decodeIfPresent(String.self, forKey: .variant) ?? "all"
|
||
subgroup = try c.decodeIfPresent(String.self, forKey: .subgroup)
|
||
groups = try c.decodeIfPresent([String].self, forKey: .groups) ?? []
|
||
uncertain = try c.decodeIfPresent(Bool.self, forKey: .uncertain) ?? false
|
||
raw = try c.decodeIfPresent(String.self, forKey: .raw) ?? ""
|
||
}
|
||
}
|
||
|
||
extension GroupsResponse {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
results = try c.decodeIfPresent([GroupResult].self, forKey: .results) ?? []
|
||
facets = try c.decodeIfPresent(Facets.self, forKey: .facets) ?? Facets()
|
||
total = try c.decodeIfPresent(Int.self, forKey: .total) ?? 0
|
||
year = try c.decodeIfPresent(String.self, forKey: .year) ?? ""
|
||
}
|
||
}
|
||
|
||
extension GroupResult {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
group = try c.decode(String.self, forKey: .group)
|
||
fileId = try c.decode(String.self, forKey: .fileId)
|
||
file = try c.decodeIfPresent(String.self, forKey: .file) ?? ""
|
||
faculty = try c.decodeIfPresent(String.self, forKey: .faculty) ?? ""
|
||
course = try c.decodeIfPresent(String.self, forKey: .course) ?? ""
|
||
form = try c.decodeIfPresent(String.self, forKey: .form) ?? ""
|
||
period = try c.decodeIfPresent(String.self, forKey: .period) ?? ""
|
||
}
|
||
}
|
||
|
||
extension Facets {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
faculties = try c.decodeIfPresent([Facet].self, forKey: .faculties) ?? []
|
||
courses = try c.decodeIfPresent([Facet].self, forKey: .courses) ?? []
|
||
forms = try c.decodeIfPresent([Facet].self, forKey: .forms) ?? []
|
||
}
|
||
}
|
||
|
||
extension DirectorySearch {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
teachers = try c.decodeIfPresent([TeacherHit].self, forKey: .teachers) ?? []
|
||
rooms = try c.decodeIfPresent([RoomHit].self, forKey: .rooms) ?? []
|
||
}
|
||
}
|
||
|
||
extension TeacherHit {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
name = try c.decode(String.self, forKey: .name)
|
||
count = try c.decodeIfPresent(Int.self, forKey: .count) ?? 0
|
||
faculties = try c.decodeIfPresent([String].self, forKey: .faculties) ?? []
|
||
}
|
||
}
|
||
|
||
extension RoomHit {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
room = try c.decode(String.self, forKey: .room)
|
||
count = try c.decodeIfPresent(Int.self, forKey: .count) ?? 0
|
||
}
|
||
}
|
||
|
||
extension DirectoryDay {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
date = try c.decode(String.self, forKey: .date)
|
||
lessons = try c.decodeIfPresent([DirectoryLesson].self, forKey: .lessons) ?? []
|
||
coverage = try c.decode(Coverage.self, forKey: .coverage)
|
||
}
|
||
}
|
||
|
||
extension DirectoryLesson {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
start = try c.decode(String.self, forKey: .start)
|
||
end = try c.decodeIfPresent(String.self, forKey: .end)
|
||
subject = try c.decode(String.self, forKey: .subject)
|
||
type = try c.decodeIfPresent(String.self, forKey: .type) ?? ""
|
||
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
|
||
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
|
||
moved = try c.decodeIfPresent(Bool.self, forKey: .moved) ?? false
|
||
place = try c.decodeIfPresent(String.self, forKey: .place)
|
||
distance = try c.decodeIfPresent(Bool.self, forKey: .distance) ?? false
|
||
groups = try c.decodeIfPresent([String].self, forKey: .groups) ?? []
|
||
condition = try c.decodeIfPresent(String.self, forKey: .condition) ?? ""
|
||
uncertain = try c.decodeIfPresent(Bool.self, forKey: .uncertain) ?? false
|
||
}
|
||
}
|
||
|
||
extension Coverage {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
documents = try c.decodeIfPresent(Int.self, forKey: .documents) ?? 0
|
||
total = try c.decodeIfPresent(Int.self, forKey: .total) ?? 0
|
||
failed = try c.decodeIfPresent([String].self, forKey: .failed) ?? []
|
||
withoutSemester = try c.decodeIfPresent([String].self, forKey: .withoutSemester) ?? []
|
||
year = try c.decodeIfPresent(String.self, forKey: .year) ?? ""
|
||
}
|
||
}
|
||
|
||
extension CalendarInfo {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
bells = try c.decodeIfPresent([Bell].self, forKey: .bells) ?? []
|
||
}
|
||
}
|
||
|
||
extension Exam {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
date = try c.decode(String.self, forKey: .date)
|
||
start = try c.decodeIfPresent(String.self, forKey: .start) ?? ""
|
||
end = try c.decodeIfPresent(String.self, forKey: .end)
|
||
type = try c.decodeIfPresent(String.self, forKey: .type) ?? ""
|
||
subject = try c.decode(String.self, forKey: .subject)
|
||
teachers = try c.decodeIfPresent([String].self, forKey: .teachers) ?? []
|
||
rooms = try c.decodeIfPresent([String].self, forKey: .rooms) ?? []
|
||
}
|
||
}
|
||
|
||
extension ExamsResponse {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
group = try c.decodeIfPresent(String.self, forKey: .group) ?? ""
|
||
exams = try c.decodeIfPresent([Exam].self, forKey: .exams) ?? []
|
||
}
|
||
}
|
||
|
||
extension SavedGroup {
|
||
init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
docId = try c.decode(String.self, forKey: .docId)
|
||
group = try c.decode(String.self, forKey: .group)
|
||
faculty = try c.decodeIfPresent(String.self, forKey: .faculty) ?? ""
|
||
course = try c.decodeIfPresent(String.self, forKey: .course) ?? ""
|
||
}
|
||
}
|