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 // Persist id + display name for creator/owner comparisons and display. UserDefaults.standard.set(user["id"] as? Int ?? 0, forKey: "userId") UserDefaults.standard.set(user["display_name"] as? String ?? uname, forKey: "displayName") return (token, uname, admin) } // MARK: - Single sign-on (OpenID Connect) /// Providers configured on the server. Empty when SSO is off or the server /// predates it — the password form is unaffected either way. static func oidcProviders(baseURL: String) async throws -> [OIDCProvider] { guard let url = URL(string: baseURL + "/api/auth/oidc/providers") else { throw APIError.invalidURL } let (data, response) = try await URLSession.shared.data(from: url) guard (response as? HTTPURLResponse)?.statusCode == 200, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], json["enabled"] as? Bool == true, let list = json["providers"] as? [[String: Any]] else { return [] } return list.compactMap { entry in // Without a mobile client id the app cannot start a flow. guard let key = entry["key"] as? String, let clientID = entry["mobile_client_id"] as? String, !clientID.isEmpty, let issuer = entry["issuer"] as? String else { return nil } return OIDCProvider( key: key, name: (entry["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? key, issuer: issuer, // mobile_scopes is what the server wants the apps to request // (it knows whether the provider grants offline_access). scopes: (entry["mobile_scopes"] as? String) ?? (entry["scopes"] as? String) ?? "openid profile email", mobileClientID: clientID ) } } /// Trade a provider ID token for a Calendarr token. /// /// The app is a public client: this request carries no secret, only the ID /// token and the nonce AppAuth bound into the flow. static func exchangeOIDC(baseURL: String, provider: String, clientID: String, idToken: String, accessToken: String?, nonce: String?) async throws -> (token: String, username: String, isAdmin: Bool) { guard let url = URL(string: baseURL + "/api/auth/oidc/exchange") else { throw APIError.invalidURL } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") var body: [String: Any] = ["provider": provider, "client_id": clientID, "id_token": idToken] if let accessToken { body["access_token"] = accessToken } if let nonce { body["nonce"] = nonce } 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 >= 400 { // The server answers with a stable slug the login screen translates. let detail = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String throw OIDCError(slug: detail?.isEmpty == false ? detail! : "oidc_failed") } 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 // Same side effect as login(): creator/owner comparisons elsewhere read // these back, so an SSO user must not be left with a zero userId. UserDefaults.standard.set(user["id"] as? Int ?? 0, forKey: "userId") UserDefaults.standard.set(user["display_name"] as? String ?? uname, forKey: "displayName") 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, isBirthday: Bool = false, birthdayNotifyDaysBefore: Int? = nil) async throws -> LocalCalendar { var body: [String: Any] = ["name": name, "color": color] if isBirthday { body["is_birthday"] = true } if let d = birthdayNotifyDaysBefore { body["birthday_notify_days_before"] = d } let data = try await request("/api/local/calendars", method: "POST", body: body) 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") } /// Update a local calendar's birthday settings. `is_birthday` marks it as a /// birthday calendar; `notifyDaysBefore` sets the reminder (nil sends the -1 /// sentinel to clear it server-side). func updateLocalCalendarBirthday(id: Int, isBirthday: Bool? = nil, notifyDaysBefore: Int? = nil) async throws { var body: [String: Any] = [:] if let b = isBirthday { body["is_birthday"] = b } if let n = notifyDaysBefore { body["birthday_notify_days_before"] = n } // -1 clears server-side guard !body.isEmpty else { return } _ = try await request("/api/local/calendars/\(id)", method: "PUT", body: body) } /// Raw (unexpanded) rows of a birthday calendar, used by the Contacts /// importer to reconcile by `external_uid`. func getBirthdayEntries(calendarId: Int) async throws -> [BirthdayEntry] { let data = try await request("/api/local/calendars/\(calendarId)/birthdays") return (try? JSONDecoder().decode([BirthdayEntry].self, from: data)) ?? [] } /// Report that this device just synced its Contacts birthdays, so the web /// can show "birthdays come from these devices". func reportBirthdaySync(deviceId: String, deviceName: String, count: Int) async throws { _ = try await request("/api/birthdays/sync-report", method: "POST", body: [ "device_id": deviceId, "device_name": deviceName, "count": count, ]) } 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 /// Fetches events for the personal calendar view. The server also reports /// per-calendar sync failures (e.g. expired credentials) alongside an /// otherwise-successful response, via the `errors` array — surfaced /// separately from a hard fetch failure so the UI can distinguish /// "everything failed" from "fetched fine, but calendar X didn't sync". func fetchEvents(start: Date, end: Date) async throws -> (events: [CalEvent], errors: [SyncError]) { // 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)") } let events = arr.compactMap { CalEvent.from(json: $0) } let errors = (root["errors"] as? [[String: Any]])?.compactMap { SyncError.from(json: $0) } ?? [] return (events, errors) } func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date, isAllDay: Bool, location: String, description: String, color: String?, isPrivate: Bool = false, reminders: [Int]? = nil, rrule: String? = nil, externalUid: String? = nil, birthYear: Int? = nil) 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, "private": isPrivate ] if let c = color, !c.isEmpty { body["color"] = c } if let reminders { body["reminders"] = reminders } if let rrule, !rrule.isEmpty { body["rrule"] = rrule } if let externalUid { body["external_uid"] = externalUid } if let birthYear { body["birth_year"] = birthYear } 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?, isPrivate: Bool = false, reminders: [Int]? = nil, rrule: String? = nil, externalUid: String? = nil, birthYear: Int? = nil) async throws { var body: [String: Any] = [ "title": title, "start": formatISO(start, allDay: isAllDay), "end": formatISO(end, allDay: isAllDay), "allDay": isAllDay, "location": location, "description": description, "private": isPrivate ] if let c = color { body["color"] = c } if let reminders { body["reminders"] = reminders } if let rrule { body["rrule"] = rrule } if let externalUid { body["external_uid"] = externalUid } if let birthYear { body["birth_year"] = birthYear } _ = 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) } /// Delete a Home Assistant calendar event. /// `calendarId` is the numeric HA-calendar DB id; `uid` is the HA event uid. func deleteHAEvent(calendarId: Int, uid: String) async throws { // uid is a path segment and may contain "/" or other reserved chars. let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) let encUid = uid.addingPercentEncoding(withAllowedCharacters: allowed) ?? uid _ = try await request("/api/homeassistant/events/\(calendarId)/\(encUid)", method: "DELETE") } // MARK: – Calendar visibility (sidebar_hidden) /// Toggle a calendar's server-side visibility. Mirrors the web: hiding sets /// `enabled=false, sidebar_hidden=true` (server then omits its events); /// showing sets `enabled=true, sidebar_hidden=false`. Only CalDAV / Google / /// Home Assistant have this flag; `local` / `ical` are a no-op. func setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Bool) async throws { let path: String switch source { case "caldav": path = "/api/caldav/calendars/\(calendarId)" case "google": path = "/api/google/calendars/\(calendarId)" case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)" case "ical": path = "/api/ical/subscriptions/\(calendarId)" default: return } _ = try await request(path, method: "PUT", body: ["enabled": !hidden, "sidebar_hidden": hidden]) } /// Toggle a calendar's server-side `reminders_enabled` flag. Supported for /// all source types (caldav/google/homeassistant/local/ical). func setCalendarRemindersEnabled(source: String, calendarId: Int, enabled: Bool) async throws { let path: String switch source { case "caldav": path = "/api/caldav/calendars/\(calendarId)" case "google": path = "/api/google/calendars/\(calendarId)" case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)" case "local": path = "/api/local/calendars/\(calendarId)" case "ical": path = "/api/ical/subscriptions/\(calendarId)" default: return } _ = try await request(path, method: "PUT", body: ["reminders_enabled": enabled]) } // MARK: – Calendar colour /// Colour-only update: works for owners AND share recipients (recipients get /// their own per-user colour; owners change the calendar's colour) — unlike /// the owner-only PUT /calendars/{id}, and never touches the name. func updateLocalCalendarColor(id: Int, color: String) async throws { _ = try await request("/api/local/calendars/\(id)/color", method: "PUT", body: ["color": color]) } func updateICalColor(id: Int, color: String) async throws { _ = try await request("/api/ical/subscriptions/\(id)", method: "PUT", body: ["color": color]) } /// Set a per-calendar colour for server-managed sources (caldav/google/homeassistant). func setCalendarColor(source: String, calendarId: Int, color: String) async throws { let path: String switch source { case "caldav": path = "/api/caldav/calendars/\(calendarId)" case "google": path = "/api/google/calendars/\(calendarId)" case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)" default: return } _ = try await request(path, method: "PUT", body: ["color": color]) } // MARK: – Profile (display name / login name / email) /// Update profile fields. A login-name change returns a fresh token (the old /// one becomes invalid) — the caller must store the returned token. func updateProfile(displayName: String?, username: String?, email: String?, directoryHidden: Bool? = nil) async throws -> String? { var body: [String: Any] = [:] if let d = displayName { body["display_name"] = d } if let u = username { body["username"] = u } if let e = email { body["email"] = e } else { body["email"] = NSNull() } if let h = directoryHidden { body["directory_hidden"] = h } let data = try await request("/api/profile/", method: "PUT", body: body) let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] return json?["access_token"] as? String } // MARK: – Targeted settings (avoid overwriting the whole AppSettings) func updatePrivateVisibility(_ value: String) async throws { _ = try await request("/api/settings/", method: "PUT", body: ["private_event_visibility": value]) } func updateGroupVisibleCalendar(_ calendarId: Int?) async throws { _ = try await request("/api/settings/", method: "PUT", body: ["group_visible_calendar_id": calendarId as Any? ?? NSNull()]) } // MARK: – Sharing func getUserDirectory() async throws -> [DirectoryUser] { let data = try await request("/api/users/directory") return (try? JSONDecoder().decode([DirectoryUser].self, from: data)) ?? [] } func getShares(calendarId: Int) async throws -> [CalendarShare] { let data = try await request("/api/local/calendars/\(calendarId)/shares") return (try? JSONDecoder().decode([CalendarShare].self, from: data)) ?? [] } func addShare(calendarId: Int, userId: Int, permission: String) async throws { _ = try await request("/api/local/calendars/\(calendarId)/shares", method: "POST", body: ["user_id": userId, "permission": permission]) } func removeShare(calendarId: Int, userId: Int) async throws { _ = try await request("/api/local/calendars/\(calendarId)/shares/\(userId)", method: "DELETE") } // MARK: – Groups func getGroups() async throws -> [CalGroup] { let data = try await request("/api/groups/") return (try? JSONDecoder().decode([CalGroup].self, from: data)) ?? [] } func getGroup(id: Int) async throws -> CalGroup { let data = try await request("/api/groups/\(id)") guard let g = try? JSONDecoder().decode(CalGroup.self, from: data) else { throw APIError.decodingError } return g } func createGroup(name: String, memberIds: [Int], icon: String?) async throws -> CalGroup { var body: [String: Any] = ["name": name, "member_ids": memberIds] if let icon { body["icon"] = icon } let data = try await request("/api/groups/", method: "POST", body: body) guard let g = try? JSONDecoder().decode(CalGroup.self, from: data) else { throw APIError.decodingError } return g } func updateGroup(id: Int, name: String?, icon: String?) async throws { var body: [String: Any] = [:] if let name { body["name"] = name } if let icon { body["icon"] = icon } _ = try await request("/api/groups/\(id)", method: "PUT", body: body) } func deleteGroup(id: Int) async throws { _ = try await request("/api/groups/\(id)", method: "DELETE") } func addGroupMember(groupId: Int, userId: Int) async throws { _ = try await request("/api/groups/\(groupId)/members", method: "POST", body: ["user_id": userId]) } func removeGroupMember(groupId: Int, userId: Int) async throws { _ = try await request("/api/groups/\(groupId)/members/\(userId)", method: "DELETE") } func setGroupMemberColor(groupId: Int, userId: Int, color: String) async throws { _ = try await request("/api/groups/\(groupId)/members/\(userId)/color", method: "PUT", body: ["color": color]) } func fetchGroupCombined(groupId: Int, start: Date, end: Date) async throws -> [CalEvent] { let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime] iso.timeZone = TimeZone(abbreviation: "UTC") let s = iso.string(from: start) 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/groups/\(groupId)/combined?start=\(sEnc)&end=\(eEnc)") guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let arr = root["events"] as? [[String: Any]] else { return [] } return arr.compactMap { CalEvent.from(json: $0) } } // MARK: – iCal import / export /// Import a .ics file into a local calendar. Returns (imported, skipped, errors). // MARK: - Attachments /// Files on an event. Only local events can have any; the server answers /// 404 for anything else, which surfaces as `serverError`. func listAttachments(eventUid: String) async throws -> [EventAttachment] { let path = "/api/local/events/\(eventUid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? eventUid)/attachments" let data = try await request(path) let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] ?? [] return arr.compactMap { EventAttachment.from(json: $0) } } /// Upload one file. The body is built by hand, exactly like `importICS`. func uploadAttachment(eventUid: String, fileURL: URL) async throws -> EventAttachment { // A URL from `.fileImporter` is security-scoped: without this the read // fails outside the app's own container. let scoped = fileURL.startAccessingSecurityScopedResource() defer { if scoped { fileURL.stopAccessingSecurityScopedResource() } } let fileData = try Data(contentsOf: fileURL) return try await uploadAttachment(eventUid: eventUid, data: fileData, filename: fileURL.lastPathComponent) } func uploadAttachment(eventUid: String, data fileData: Data, filename: String) async throws -> EventAttachment { let encodedUid = eventUid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? eventUid guard let url = URL(string: baseURL + "/api/local/events/\(encodedUid)/attachments") else { throw APIError.invalidURL } let boundary = "Boundary-\(UUID().uuidString)" var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") var bodyData = Data() let name = filename.isEmpty ? "anhang" : filename bodyData.append("--\(boundary)\r\n".data(using: .utf8)!) bodyData.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(name)\"\r\n".data(using: .utf8)!) // The server sniffs the bytes and ignores this header, but something // has to be sent for the part to be well formed. bodyData.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!) bodyData.append(fileData) bodyData.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) req.httpBody = bodyData 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) } guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let att = EventAttachment.from(json: json) else { throw APIError.serverError("Unerwartete Antwort") } return att } func deleteAttachment(id: Int) async throws { _ = try await request("/api/local/attachments/\(id)", method: "DELETE") } /// Raw bytes, for saving or sharing. func downloadAttachment(id: Int) async throws -> Data { try await request("/api/local/attachments/\(id)") } /// Small JPEG preview, or nil when there is none. Never throws: a missing /// thumbnail just means the row shows its type icon. func attachmentThumbnail(id: Int) async -> Data? { try? await request("/api/local/attachments/\(id)/thumb") } func importICS(calendarId: Int, fileURL: URL) async throws -> (imported: Int, skipped: Int, errors: [String]) { guard let url = URL(string: baseURL + "/api/local/calendars/\(calendarId)/import") else { throw APIError.invalidURL } let fileData = try Data(contentsOf: fileURL) let boundary = "Boundary-\(UUID().uuidString)" var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") var bodyData = Data() let filename = fileURL.lastPathComponent.isEmpty ? "import.ics" : fileURL.lastPathComponent bodyData.append("--\(boundary)\r\n".data(using: .utf8)!) bodyData.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) bodyData.append("Content-Type: text/calendar\r\n\r\n".data(using: .utf8)!) bodyData.append(fileData) bodyData.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) req.httpBody = bodyData 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) } let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] let errs = (json?["errors"] as? [String]) ?? [] return (json?["imported"] as? Int ?? 0, json?["skipped"] as? Int ?? 0, errs) } /// Export a local calendar as raw .ics bytes. func exportICS(calendarId: Int) async throws -> Data { return try await request("/api/local/calendars/\(calendarId)/export") } }