Files
psu-schedule/ios/Shared/Models.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

477 lines
17 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
// 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) ?? ""
}
}