Add date and time handling, models, schedule management, storage, and widget support
- 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.
This commit is contained in:
113
ios/PSUSchedule/API.swift
Normal file
113
ios/PSUSchedule/API.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// An error with a message ready to show; `offline` when the server could not be reached.
|
||||
struct APIError: LocalizedError {
|
||||
var message: String
|
||||
var offline = false
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// The schedule server's HTTP API, as the Android client's Api.kt.
|
||||
struct API: Sendable {
|
||||
let base: URL
|
||||
|
||||
/// The server address: Info.plist's PSUAPIURL; Debug builds may set PSU_API_URL instead.
|
||||
static let shared: API = {
|
||||
#if DEBUG
|
||||
if let env = ProcessInfo.processInfo.environment["PSU_API_URL"], let url = URL(string: env) {
|
||||
return API(base: url)
|
||||
}
|
||||
#endif
|
||||
let plist = Bundle.main.object(forInfoDictionaryKey: "PSUAPIURL") as? String
|
||||
return API(base: URL(string: plist ?? "https://psu.rumblur.by/")!)
|
||||
}()
|
||||
|
||||
private static let log = Logger(subsystem: "org.xtimms.psu.schedule", category: "API")
|
||||
private static let offline = "Нет связи с сервером расписания."
|
||||
private static let retries = 3
|
||||
|
||||
// The server answers "no-cache" with an ETag: URLSession keeps the answer and asks again
|
||||
// with If-None-Match, so an unchanged semester comes back as an empty 304.
|
||||
private static let session: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 60
|
||||
config.urlCache = URLCache(memoryCapacity: 2 << 20, diskCapacity: 10 << 20)
|
||||
config.requestCachePolicy = .useProtocolCachePolicy
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
private func get<T: Decodable & Sendable>(_ path: String, _ query: [(String, String?)] = []) async throws -> T {
|
||||
var components = URLComponents(url: base.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
|
||||
let items = query.compactMap { name, value in value.flatMap { $0.isEmpty ? nil : URLQueryItem(name: name, value: $0) } }
|
||||
if !items.isEmpty { components.queryItems = items }
|
||||
let url = components.url!
|
||||
let (data, response) = try await withRetry(url)
|
||||
guard let http = response as? HTTPURLResponse else { throw APIError(message: Self.offline, offline: true) }
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String
|
||||
// The server's catch-all 404 means it runs an older version without this endpoint.
|
||||
if http.statusCode == 404 && message == "Адрес не найден." {
|
||||
throw APIError(message: "Сервер расписания не знает запрос /\(path) — на нём старая версия. Обновите сервер.")
|
||||
}
|
||||
throw APIError(message: message ?? "Сервер ответил ошибкой \(http.statusCode).")
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
Self.log.error("Decoding \(path) failed: \(error)")
|
||||
throw APIError(message: "Не удалось прочитать ответ сервера.")
|
||||
}
|
||||
}
|
||||
|
||||
/// A dropped or timed-out connection is retried twice (after 1 and 3 seconds):
|
||||
/// a flaky mobile network often recovers within seconds. Server answers are not retried.
|
||||
private func withRetry(_ url: URL) async throws -> (Data, URLResponse) {
|
||||
var wait: UInt64 = 1_000_000_000
|
||||
for attempt in 1...Self.retries {
|
||||
do {
|
||||
return try await Self.session.data(from: url)
|
||||
} catch let error as URLError where error.code != .cancelled {
|
||||
Self.log.warning("GET \(url) failed (attempt \(attempt)): \(error.localizedDescription)")
|
||||
if attempt == Self.retries { break }
|
||||
try await Task.sleep(nanoseconds: wait)
|
||||
wait *= 3
|
||||
}
|
||||
}
|
||||
throw APIError(message: Self.offline, offline: true)
|
||||
}
|
||||
|
||||
/// The whole current semester; a server without range=semester answers the from/days window.
|
||||
func timetable(doc: String, group: String, from: CivilDate, days: Int) async throws -> Timetable {
|
||||
try await get("api/timetable", [("doc", doc), ("group", group), ("from", from.iso), ("days", String(days)), ("range", "semester")])
|
||||
}
|
||||
|
||||
func groups(query: String = "", faculty: String = "", course: String = "") async throws -> GroupsResponse {
|
||||
try await get("api/groups", [("q", query), ("faculty", faculty), ("course", course)])
|
||||
}
|
||||
|
||||
func searchDirectory(_ query: String) async throws -> DirectorySearch {
|
||||
try await get("api/directory/search", [("q", query)])
|
||||
}
|
||||
|
||||
func teacherDay(_ name: String, date: CivilDate) async throws -> DirectoryDay {
|
||||
try await get("api/directory/day", [("teacher", name), ("date", date.iso)])
|
||||
}
|
||||
|
||||
func roomDay(_ room: String, date: CivilDate) async throws -> DirectoryDay {
|
||||
try await get("api/directory/day", [("room", room), ("date", date.iso)])
|
||||
}
|
||||
|
||||
func calendar() async throws -> CalendarInfo { try await get("api/calendar") }
|
||||
|
||||
func exams(group: String, faculty: String) async throws -> ExamsResponse {
|
||||
try await get("api/exams", [("group", group), ("faculty", faculty)])
|
||||
}
|
||||
}
|
||||
|
||||
extension Timetable: @unchecked Sendable {}
|
||||
extension GroupsResponse: @unchecked Sendable {}
|
||||
extension DirectorySearch: @unchecked Sendable {}
|
||||
extension DirectoryDay: @unchecked Sendable {}
|
||||
extension CalendarInfo: @unchecked Sendable {}
|
||||
extension ExamsResponse: @unchecked Sendable {}
|
||||
Reference in New Issue
Block a user