Initial Commit

This commit is contained in:
Scarriffle
2026-05-17 08:32:34 +02:00
commit e5529ca653
30 changed files with 4351 additions and 0 deletions

View File

@@ -0,0 +1,368 @@
import Foundation
enum APIError: LocalizedError {
case invalidURL
case unauthorized
case twoFactorRequired
case serverError(String)
case decodingError
var errorDescription: String? {
switch self {
case .invalidURL: return "Ungültige Server-URL"
case .unauthorized: return "Benutzername oder Passwort falsch"
case .twoFactorRequired: return "2FA-Code erforderlich"
case .serverError(let msg): return msg
case .decodingError: return "Antwort konnte nicht verarbeitet werden"
}
}
}
class CalendarrAPI {
let baseURL: String
let token: String
init(baseURL: String, token: String) {
self.baseURL = baseURL
self.token = token
}
private func request(_ path: String, method: String = "GET", body: [String: Any]? = nil) async throws -> Data {
guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if let body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
let (data, response) = try await URLSession.shared.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status == 401 { throw APIError.unauthorized }
if status >= 400 {
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? "Fehler \(status)"
throw APIError.serverError(msg)
}
return data
}
static func login(baseURL: String, username: String, password: String, totpCode: String? = nil, rememberMe: Bool = false) async throws -> (token: String, username: String, isAdmin: Bool) {
guard let url = URL(string: baseURL + "/api/auth/login") else { throw APIError.invalidURL }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = ["username": username, "password": password, "remember_me": rememberMe]
if let code = totpCode { body["totp_code"] = code }
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status == 401 {
let detail = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? ""
if detail == "2fa_required" { throw APIError.twoFactorRequired }
throw APIError.unauthorized
}
if status >= 400 {
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? "Fehler"
throw APIError.serverError(msg)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = json["access_token"] as? String,
let user = json["user"] as? [String: Any],
let uname = user["username"] as? String else {
throw APIError.decodingError
}
let admin = user["is_admin"] as? Bool ?? false
return (token, uname, admin)
}
static func checkSetupRequired(baseURL: String) async throws -> Bool {
guard let url = URL(string: baseURL + "/api/auth/setup-required") else { throw APIError.invalidURL }
let (data, _) = try await URLSession.shared.data(from: url)
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
return json?["required"] as? Bool ?? false
}
func getSettings() async throws -> AppSettings {
let data = try await request("/api/settings/")
guard let settings = try? JSONDecoder().decode(AppSettings.self, from: data) else { throw APIError.decodingError }
return settings
}
func updateSettings(_ settings: AppSettings) async throws {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
guard let body = try? encoder.encode(settings),
let dict = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { return }
_ = try await request("/api/settings/", method: "PUT", body: dict)
}
func getProfile() async throws -> UserProfile {
let data = try await request("/api/auth/me")
guard let profile = try? JSONDecoder().decode(UserProfile.self, from: data) else { throw APIError.decodingError }
return profile
}
func updateEmail(_ email: String) async throws {
_ = try await request("/api/profile/", method: "PATCH", body: ["email": email])
}
func changePassword(current: String, new: String) async throws {
_ = try await request("/api/profile/password", method: "POST", body: ["current_password": current, "new_password": new])
}
func getCalDAVAccounts() async throws -> [CalDAVAccount] {
let data = try await request("/api/caldav/accounts")
return (try? JSONDecoder().decode([CalDAVAccount].self, from: data)) ?? []
}
func addCalDAVAccount(name: String, url: String, username: String, password: String, color: String) async throws -> CalDAVAccount {
let data = try await request("/api/caldav/accounts", method: "POST", body: [
"name": name, "url": url, "username": username, "password": password, "color": color
])
guard let acc = try? JSONDecoder().decode(CalDAVAccount.self, from: data) else { throw APIError.decodingError }
return acc
}
func deleteCalDAVAccount(id: Int) async throws {
_ = try await request("/api/caldav/accounts/\(id)", method: "DELETE")
}
func getLocalCalendars() async throws -> [LocalCalendar] {
let data = try await request("/api/local/calendars")
return (try? JSONDecoder().decode([LocalCalendar].self, from: data)) ?? []
}
func addLocalCalendar(name: String, color: String) async throws -> LocalCalendar {
let data = try await request("/api/local/calendars", method: "POST", body: ["name": name, "color": color])
guard let cal = try? JSONDecoder().decode(LocalCalendar.self, from: data) else { throw APIError.decodingError }
return cal
}
func deleteLocalCalendar(id: Int) async throws {
_ = try await request("/api/local/calendars/\(id)", method: "DELETE")
}
func getICalSubscriptions() async throws -> [ICalSubscription] {
let data = try await request("/api/ical/subscriptions")
return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? []
}
func addICalSubscription(name: String, url: String, color: String, refreshMinutes: Int) async throws -> ICalSubscription {
let data = try await request("/api/ical/subscriptions", method: "POST", body: [
"name": name, "url": url, "color": color, "refresh_minutes": refreshMinutes
])
guard let sub = try? JSONDecoder().decode(ICalSubscription.self, from: data) else { throw APIError.decodingError }
return sub
}
func deleteICalSubscription(id: Int) async throws {
_ = try await request("/api/ical/subscriptions/\(id)", method: "DELETE")
}
func getGoogleAccounts() async throws -> [GoogleAccount] {
let data = try await request("/api/google/accounts")
return (try? JSONDecoder().decode([GoogleAccount].self, from: data)) ?? []
}
func deleteGoogleAccount(id: Int) async throws {
_ = try await request("/api/google/accounts/\(id)", method: "DELETE")
}
func getHomeAssistantAccounts() async throws -> [HomeAssistantAccount] {
let data = try await request("/api/homeassistant/accounts")
return (try? JSONDecoder().decode([HomeAssistantAccount].self, from: data)) ?? []
}
func addHomeAssistantAccount(name: String, url: String, token: String) async throws -> HomeAssistantAccount {
let data = try await request("/api/homeassistant/accounts", method: "POST", body: [
"name": name, "url": url, "token": token, "auth_method": "token"
])
guard let acc = try? JSONDecoder().decode(HomeAssistantAccount.self, from: data) else { throw APIError.decodingError }
return acc
}
func deleteHomeAssistantAccount(id: Int) async throws {
_ = try await request("/api/homeassistant/accounts/\(id)", method: "DELETE")
}
func setup2FA() async throws -> (secret: String, qrUrl: String) {
let data = try await request("/api/profile/2fa/setup", method: "POST")
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let secret = json["secret"] as? String,
let qr = json["qr_url"] as? String else { throw APIError.decodingError }
return (secret, qr)
}
func enable2FA(code: String) async throws {
_ = try await request("/api/profile/2fa/enable", method: "POST", body: ["code": code])
}
func disable2FA(password: String) async throws {
_ = try await request("/api/profile/2fa/disable", method: "POST", body: ["password": password])
}
// MARK: Events
func fetchEvents(start: Date, end: Date) async throws -> [CalEvent] {
// Use UTC with Z suffix avoids '+' character which breaks URL query params
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
iso.timeZone = TimeZone(abbreviation: "UTC")
let s = iso.string(from: start) // e.g. "2026-05-01T00:00:00Z"
let e = iso.string(from: end)
let sEnc = s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? s
let eEnc = e.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? e
let data = try await request("/api/caldav/events?start=\(sEnc)&end=\(eEnc)")
// Server returns {"events": [...], "errors": [...]}
guard
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let arr = root["events"] as? [[String: Any]]
else {
let preview = String(data: data, encoding: .utf8).map { String($0.prefix(200)) } ?? "no data"
throw APIError.serverError("Unerwartete Antwort: \(preview)")
}
return arr.compactMap { CalEvent.from(json: $0) }
}
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String, color: String?) async throws -> CalEvent {
var body: [String: Any] = [
"calendar_id": calendarId,
"title": title,
"start": formatISO(start, allDay: isAllDay),
"end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay,
"location": location,
"description": description
]
if let c = color, !c.isEmpty { body["color"] = c }
let data = try await request("/api/local/events", method: "POST", body: body)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
return ev
}
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String, color: String?) async throws {
var body: [String: Any] = [
"title": title,
"start": formatISO(start, allDay: isAllDay),
"end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay,
"location": location,
"description": description
]
if let c = color { body["color"] = c }
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
}
func deleteLocalEvent(uid: String) async throws {
_ = try await request("/api/local/events/\(uid)", method: "DELETE")
}
func createCalDAVEvent(calendarId: Int, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String, color: String?) async throws {
var body: [String: Any] = [
"calendar_id": calendarId,
"title": title,
"start": formatISO(start, allDay: isAllDay),
"end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay,
"location": location,
"description": description
]
if let c = color, !c.isEmpty { body["color"] = c }
_ = try await request("/api/caldav/events", method: "POST", body: body)
}
func updateCalDAVEvent(uid: String, url: String, calendarId: Int?, title: String,
start: Date, end: Date, isAllDay: Bool,
location: String, description: String, color: String?) async throws {
var body: [String: Any] = [
"title": title,
"start": formatISO(start, allDay: isAllDay),
"end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay,
"location": location,
"description": description
]
if let c = color { body["color"] = c }
let encURL = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? url
var path = "/api/caldav/events/\(uid)?event_url=\(encURL)"
if let cid = calendarId { path += "&calendar_id=\(cid)" }
_ = try await request(path, method: "PUT", body: body)
}
func deleteCalDAVEvent(uid: String, url: String, calendarId: Int?) async throws {
let encURL = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? url
var path = "/api/caldav/events/\(uid)?event_url=\(encURL)"
if let cid = calendarId { path += "&calendar_id=\(cid)" }
_ = try await request(path, method: "DELETE")
}
// MARK: Google Calendar events
func getGoogleCalendars() async throws -> [(accountEmail: String, calId: Int, name: String, color: String)] {
let data = try await request("/api/google/accounts")
guard let accounts = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
var result: [(String, Int, String, String)] = []
for acc in accounts {
let email = acc["email"] as? String ?? "Google"
let cals = acc["calendars"] as? [[String: Any]] ?? []
for cal in cals where (cal["enabled"] as? Bool ?? true) {
if let id = cal["id"] as? Int, let name = cal["name"] as? String {
let color = cal["color"] as? String ?? "#4285f4"
result.append((email, id, name, color))
}
}
}
return result
}
func createGoogleEvent(calendarDbId: Int, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String) async throws {
let body: [String: Any] = [
"calendar_db_id": calendarDbId,
"title": title,
"start": formatISO(start, allDay: isAllDay),
"end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay,
"location": location,
"description": description
]
_ = try await request("/api/google/events", method: "POST", body: body)
}
// MARK: Home Assistant events
func getHACalendars() async throws -> [(accountName: String, calId: Int, name: String, color: String)] {
let data = try await request("/api/homeassistant/accounts")
guard let accounts = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
var result: [(String, Int, String, String)] = []
for acc in accounts {
let aName = acc["name"] as? String ?? "Home Assistant"
let cals = acc["calendars"] as? [[String: Any]] ?? []
for cal in cals where (cal["enabled"] as? Bool ?? true) {
if let id = cal["id"] as? Int, let name = cal["name"] as? String {
let color = cal["color"] as? String ?? "#46bdc6"
result.append((aName, id, name, color))
}
}
}
return result
}
func createHAEvent(calendarId: Int, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String) async throws {
let body: [String: Any] = [
"calendar_id": calendarId,
"title": title,
"start": formatISO(start, allDay: isAllDay),
"end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay,
"location": location,
"description": description
]
_ = try await request("/api/homeassistant/events", method: "POST", body: body)
}
}