- Introduced `Dates.swift` for handling date and time in the university's timezone. - Created `Models.swift` to define data structures for timetable, lessons, and directory search results. - Implemented `Schedule.swift` for managing lesson slots and timetable functionalities. - Developed `Storage.swift` for user settings and data persistence. - Added `Widgets.swift` to create widgets displaying lessons and upcoming classes.
433 lines
15 KiB
Swift
433 lines
15 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] = []
|
|
}
|
|
|
|
struct WeeklyDay: Codable, Hashable {
|
|
var day: Int
|
|
var lessons: [Lesson] = []
|
|
}
|
|
|
|
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
|
|
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 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) ?? []
|
|
}
|
|
}
|
|
|
|
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) ?? []
|
|
}
|
|
}
|
|
|
|
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
|
|
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
|
|
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) ?? ""
|
|
}
|
|
}
|