chore(repo): iOS-App als ios/ ins Hauptrepo uebernehmen
Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem gemeinsamen Repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AppSettings: Codable {
|
||||
var defaultView: String = "month"
|
||||
var weekStartDay: String = "monday"
|
||||
var primaryColor: String = "#4285f4"
|
||||
var accentColor: String = "#ea4335"
|
||||
var todayColor: String = "#4285f4"
|
||||
var dimPastEvents: Bool = false
|
||||
var textContrast: Int = 3
|
||||
var lineContrast: Int = 3
|
||||
var hourHeight: Int = 60
|
||||
var language: String = "de"
|
||||
var monthDividerColor: String = "#7090c0"
|
||||
var monthLabelColor: String = "#7090c0"
|
||||
var textColor: String = "#FFFFFF"
|
||||
var backgroundColor: String = "#000000"
|
||||
var lineColor: String = "#3A3A52"
|
||||
var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
|
||||
var groupVisibleCalendarId: Int? = nil
|
||||
var defaultReminderMinutes: Int? = nil // minutes before start; nil = off
|
||||
var defaultEventDurationMinutes: Int = 60 // applied to a new event's end time
|
||||
var cacheMonths: Int = 3 // preloaded month range (device-local by default)
|
||||
var monthViewPaged: Bool = false // month view swipe-paging (device-local by default)
|
||||
var syncFlags: [String: Bool]? = nil // server-resolved per-setting sync flags
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case defaultView = "default_view"
|
||||
case weekStartDay = "week_start_day"
|
||||
case primaryColor = "primary_color"
|
||||
case accentColor = "accent_color"
|
||||
case todayColor = "today_color"
|
||||
case dimPastEvents = "dim_past_events"
|
||||
case textContrast = "text_contrast"
|
||||
case lineContrast = "line_contrast"
|
||||
case hourHeight = "hour_height"
|
||||
case language
|
||||
case monthDividerColor = "month_divider_color"
|
||||
case monthLabelColor = "month_label_color"
|
||||
case textColor = "text_color"
|
||||
case backgroundColor = "bg_color"
|
||||
case lineColor = "line_color"
|
||||
case privateEventVisibility = "private_event_visibility"
|
||||
case groupVisibleCalendarId = "group_visible_calendar_id"
|
||||
case defaultReminderMinutes = "default_reminder_minutes"
|
||||
case defaultEventDurationMinutes = "default_event_duration_minutes"
|
||||
case cacheMonths = "cache_months"
|
||||
case monthViewPaged = "month_view_paged"
|
||||
case syncFlags = "sync_flags"
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
/// Resilient decoding: a server may omit some keys. Using `decodeIfPresent`
|
||||
/// with the property defaults means a missing key no longer aborts the whole
|
||||
/// decode — otherwise the entire settings sync silently breaks. (Note:
|
||||
/// `background_color` was previously mis-mapped; the server column is
|
||||
/// `bg_color`, now corrected above.)
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let d = AppSettings()
|
||||
defaultView = try c.decodeIfPresent(String.self, forKey: .defaultView) ?? d.defaultView
|
||||
weekStartDay = try c.decodeIfPresent(String.self, forKey: .weekStartDay) ?? d.weekStartDay
|
||||
primaryColor = try c.decodeIfPresent(String.self, forKey: .primaryColor) ?? d.primaryColor
|
||||
accentColor = try c.decodeIfPresent(String.self, forKey: .accentColor) ?? d.accentColor
|
||||
todayColor = try c.decodeIfPresent(String.self, forKey: .todayColor) ?? d.todayColor
|
||||
dimPastEvents = try c.decodeIfPresent(Bool.self, forKey: .dimPastEvents) ?? d.dimPastEvents
|
||||
textContrast = try c.decodeIfPresent(Int.self, forKey: .textContrast) ?? d.textContrast
|
||||
lineContrast = try c.decodeIfPresent(Int.self, forKey: .lineContrast) ?? d.lineContrast
|
||||
hourHeight = try c.decodeIfPresent(Int.self, forKey: .hourHeight) ?? d.hourHeight
|
||||
language = try c.decodeIfPresent(String.self, forKey: .language) ?? d.language
|
||||
monthDividerColor = try c.decodeIfPresent(String.self, forKey: .monthDividerColor) ?? d.monthDividerColor
|
||||
monthLabelColor = try c.decodeIfPresent(String.self, forKey: .monthLabelColor) ?? d.monthLabelColor
|
||||
textColor = try c.decodeIfPresent(String.self, forKey: .textColor) ?? d.textColor
|
||||
backgroundColor = try c.decodeIfPresent(String.self, forKey: .backgroundColor) ?? d.backgroundColor
|
||||
lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor
|
||||
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
|
||||
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
|
||||
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
|
||||
defaultEventDurationMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultEventDurationMinutes) ?? d.defaultEventDurationMinutes
|
||||
cacheMonths = try c.decodeIfPresent(Int.self, forKey: .cacheMonths) ?? d.cacheMonths
|
||||
monthViewPaged = try c.decodeIfPresent(Bool.self, forKey: .monthViewPaged) ?? d.monthViewPaged
|
||||
syncFlags = try c.decodeIfPresent([String: Bool].self, forKey: .syncFlags)
|
||||
}
|
||||
}
|
||||
|
||||
struct CalDAVAccount: Codable, Identifiable {
|
||||
let id: Int
|
||||
var name: String
|
||||
var url: String
|
||||
var username: String
|
||||
var color: String
|
||||
var enabled: Bool
|
||||
var calendars: [CalDAVCalendar]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, url, username, color, enabled, calendars
|
||||
}
|
||||
}
|
||||
|
||||
struct CalDAVCalendar: Codable, Identifiable {
|
||||
let id: Int
|
||||
var name: String
|
||||
var color: String?
|
||||
var enabled: Bool
|
||||
var sidebarHidden: Bool
|
||||
var remindersEnabled: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled
|
||||
case sidebarHidden = "sidebar_hidden"
|
||||
case remindersEnabled = "reminders_enabled"
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalCalendar: Codable, Identifiable {
|
||||
let id: Int
|
||||
var name: String
|
||||
var color: String
|
||||
var enabled: Bool
|
||||
var owned: Bool = true
|
||||
var sharedBy: String? = nil
|
||||
var permission: String? = nil
|
||||
var group: Bool = false
|
||||
var remindersEnabled: Bool = true
|
||||
// Birthday calendar: events are all-day yearly; server renders age + cake icon.
|
||||
var isBirthday: Bool = false
|
||||
// Days before a birthday to remind (0 = on the day). nil = no reminder.
|
||||
var birthdayNotifyDaysBefore: Int? = nil
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled, owned, permission, group
|
||||
case sharedBy = "shared_by"
|
||||
case remindersEnabled = "reminders_enabled"
|
||||
case isBirthday = "is_birthday"
|
||||
case birthdayNotifyDaysBefore = "birthday_notify_days_before"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(Int.self, forKey: .id)
|
||||
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
color = try c.decodeIfPresent(String.self, forKey: .color) ?? "#34a853"
|
||||
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
|
||||
owned = try c.decodeIfPresent(Bool.self, forKey: .owned) ?? true
|
||||
sharedBy = try c.decodeIfPresent(String.self, forKey: .sharedBy)
|
||||
permission = try c.decodeIfPresent(String.self, forKey: .permission)
|
||||
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
|
||||
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? true
|
||||
isBirthday = try c.decodeIfPresent(Bool.self, forKey: .isBirthday) ?? false
|
||||
birthdayNotifyDaysBefore = try c.decodeIfPresent(Int.self, forKey: .birthdayNotifyDaysBefore)
|
||||
}
|
||||
}
|
||||
|
||||
struct ICalSubscription: Codable, Identifiable {
|
||||
let id: Int
|
||||
var name: String
|
||||
var url: String
|
||||
var color: String
|
||||
var enabled: Bool
|
||||
var refreshMinutes: Int
|
||||
var lastFetched: String?
|
||||
var remindersEnabled: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, url, color, enabled
|
||||
case refreshMinutes = "refresh_minutes"
|
||||
case lastFetched = "last_fetched"
|
||||
case remindersEnabled = "reminders_enabled"
|
||||
}
|
||||
}
|
||||
|
||||
struct GoogleAccount: Codable, Identifiable {
|
||||
let id: Int
|
||||
var email: String
|
||||
var calendars: [GoogleCalendar]?
|
||||
}
|
||||
|
||||
struct GoogleCalendar: Codable, Identifiable {
|
||||
let id: Int
|
||||
var name: String
|
||||
var color: String?
|
||||
var enabled: Bool
|
||||
var sidebarHidden: Bool
|
||||
var remindersEnabled: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled
|
||||
case sidebarHidden = "sidebar_hidden"
|
||||
case remindersEnabled = "reminders_enabled"
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeAssistantAccount: Codable, Identifiable {
|
||||
let id: Int
|
||||
var name: String
|
||||
var url: String
|
||||
var authMethod: String
|
||||
var calendars: [HACalendar]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, url, calendars
|
||||
case authMethod = "auth_method"
|
||||
}
|
||||
}
|
||||
|
||||
struct HACalendar: Codable, Identifiable {
|
||||
let id: Int
|
||||
var name: String
|
||||
var entityId: String
|
||||
var color: String?
|
||||
var enabled: Bool
|
||||
var sidebarHidden: Bool
|
||||
var remindersEnabled: Bool = true
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled
|
||||
case entityId = "entity_id"
|
||||
case sidebarHidden = "sidebar_hidden"
|
||||
case remindersEnabled = "reminders_enabled"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(Int.self, forKey: .id)
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
entityId = try c.decodeIfPresent(String.self, forKey: .entityId) ?? ""
|
||||
color = try c.decodeIfPresent(String.self, forKey: .color)
|
||||
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
|
||||
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
|
||||
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? true
|
||||
}
|
||||
}
|
||||
|
||||
struct UserProfile: Codable {
|
||||
let id: Int
|
||||
let username: String
|
||||
var displayName: String?
|
||||
var email: String?
|
||||
let isAdmin: Bool
|
||||
let hasAvatar: Bool
|
||||
let totpEnabled: Bool
|
||||
var directoryHidden: Bool = false
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, email
|
||||
case displayName = "display_name"
|
||||
case isAdmin = "is_admin"
|
||||
case hasAvatar = "has_avatar"
|
||||
case totpEnabled = "totp_enabled"
|
||||
case directoryHidden = "directory_hidden"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(Int.self, forKey: .id)
|
||||
username = try c.decode(String.self, forKey: .username)
|
||||
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||
email = try c.decodeIfPresent(String.self, forKey: .email)
|
||||
isAdmin = try c.decodeIfPresent(Bool.self, forKey: .isAdmin) ?? false
|
||||
hasAvatar = try c.decodeIfPresent(Bool.self, forKey: .hasAvatar) ?? false
|
||||
totpEnabled = try c.decodeIfPresent(Bool.self, forKey: .totpEnabled) ?? false
|
||||
directoryHidden = try c.decodeIfPresent(Bool.self, forKey: .directoryHidden) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sharing & groups
|
||||
|
||||
struct DirectoryUser: Codable, Identifiable {
|
||||
let id: Int
|
||||
let displayName: String
|
||||
enum CodingKeys: String, CodingKey { case id; case displayName = "display_name" }
|
||||
}
|
||||
|
||||
struct CalendarShare: Codable, Identifiable {
|
||||
let userId: Int
|
||||
let displayName: String?
|
||||
var permission: String
|
||||
var id: Int { userId }
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case displayName = "display_name"
|
||||
case permission
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupMember: Codable, Identifiable {
|
||||
let id: Int
|
||||
let displayName: String?
|
||||
var role: String
|
||||
var color: String?
|
||||
var sharesCalendar: Bool = true
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, role, color
|
||||
case displayName = "display_name"
|
||||
case sharesCalendar = "shares_calendar"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(Int.self, forKey: .id)
|
||||
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "member"
|
||||
color = try c.decodeIfPresent(String.self, forKey: .color)
|
||||
sharesCalendar = try c.decodeIfPresent(Bool.self, forKey: .sharesCalendar) ?? true
|
||||
}
|
||||
}
|
||||
|
||||
struct CalGroup: Codable, Identifiable {
|
||||
let id: Int
|
||||
var name: String
|
||||
var icon: String?
|
||||
var role: String?
|
||||
var memberCount: Int?
|
||||
var groupCalendarId: Int?
|
||||
var groupCalendarColor: String?
|
||||
var members: [GroupMember]?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, icon, role, members
|
||||
case memberCount = "member_count"
|
||||
case groupCalendarId = "group_calendar_id"
|
||||
case groupCalendarColor = "group_calendar_color"
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let r, g, b: UInt64
|
||||
switch hex.count {
|
||||
case 6:
|
||||
(r, g, b) = ((int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(r, g, b) = (0, 0, 0)
|
||||
}
|
||||
self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255)
|
||||
}
|
||||
|
||||
func toHex() -> String {
|
||||
let uiColor = UIColor(self)
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
uiColor.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
return String(format: "#%02X%02X%02X", Int(r * 255), Int(g * 255), Int(b * 255))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Creator (or owner, in the group combined view) of an event.
|
||||
/// `id` is nil for imported events.
|
||||
struct EventPerson: Hashable {
|
||||
let id: Int?
|
||||
let displayName: String
|
||||
|
||||
static func from(_ json: Any?) -> EventPerson? {
|
||||
guard let obj = json as? [String: Any],
|
||||
let name = obj["display_name"] as? String, !name.isEmpty else { return nil }
|
||||
let id: Int?
|
||||
if let n = obj["id"] as? Int { id = n }
|
||||
else if let s = obj["id"] as? String { id = Int(s) }
|
||||
else { id = nil }
|
||||
return EventPerson(id: id, displayName: name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A partial-sync failure reported alongside an otherwise-successful
|
||||
/// `/api/caldav/events` response: one specific calendar didn't sync (e.g.
|
||||
/// expired credentials) even though it's still enabled. Distinct from a
|
||||
/// hard fetch failure (`CalendarStore.lastError`) — the request itself
|
||||
/// succeeded, just not every source within it.
|
||||
struct SyncError: Hashable {
|
||||
let source: String
|
||||
let calendarId: String? // nil on older server responses without calendar_id
|
||||
let name: String
|
||||
let message: String
|
||||
|
||||
static func from(json: [String: Any]) -> SyncError? {
|
||||
guard
|
||||
let source = json["source"] as? String,
|
||||
let name = json["name"] as? String,
|
||||
let message = json["message"] as? String
|
||||
else { return nil }
|
||||
let calendarId = json["calendar_id"].map { "\($0)" }
|
||||
return SyncError(source: source, calendarId: calendarId, name: name, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
struct CalEvent: Identifiable, Hashable {
|
||||
let id: String
|
||||
let url: String
|
||||
var title: String
|
||||
var startDate: Date
|
||||
var endDate: Date
|
||||
var isAllDay: Bool
|
||||
var location: String
|
||||
var notes: String
|
||||
var color: String?
|
||||
var calendarId: String
|
||||
var calendarName: String
|
||||
var calendarColor: String
|
||||
var source: String
|
||||
var creator: EventPerson? = nil
|
||||
var isPrivate: Bool = false
|
||||
// Only set in the group combined view:
|
||||
var owner: EventPerson? = nil
|
||||
var isGroupEvent: Bool = false
|
||||
var displayColor: String? = nil
|
||||
// Server-decorated title for the group combined view (group icon / owner
|
||||
// prefix); rendered in group mode while `title` stays raw for editing.
|
||||
var displayTitle: String? = nil
|
||||
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
|
||||
var reminders: [Int] = []
|
||||
// True for events from a calendar shared with the user read-only.
|
||||
var readOnly: Bool = false
|
||||
// True for events from a birthday calendar — clients show a cake icon and the
|
||||
// server bakes the age into `displayTitle`.
|
||||
var isBirthday: Bool = false
|
||||
|
||||
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
||||
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
||||
|
||||
// Title to render: the server-decorated one (birthday age, group prefix) wins
|
||||
// over the raw title, which is kept for editing.
|
||||
var renderTitle: String { displayTitle ?? title }
|
||||
|
||||
static func from(json: [String: Any]) -> CalEvent? {
|
||||
guard
|
||||
let title = json["title"] as? String,
|
||||
let startStr = json["start"] as? String,
|
||||
let endStr = json["end"] as? String
|
||||
else { return nil }
|
||||
|
||||
// id can be String (local UUID) or Int (CalDAV numeric)
|
||||
let id: String
|
||||
if let s = json["id"] as? String { id = s }
|
||||
else if let n = json["id"] as? Int { id = String(n) }
|
||||
else { return nil }
|
||||
|
||||
// A date-only string is an all-day event whether or not the flag says
|
||||
// so — `parseDate` already treats it that way. Trusting the flag alone
|
||||
// is what put "00:00" in front of birthdays everywhere they are shown.
|
||||
let startIsDateOnly: Bool = {
|
||||
let clean = startStr.trimmingCharacters(in: .whitespaces)
|
||||
return clean.count == 10 && !clean.contains("T")
|
||||
}()
|
||||
let declaredAllDay = (json["allDay"] as? Bool ?? false) || startIsDateOnly
|
||||
let startDate = parseDate(startStr, allDay: declaredAllDay)
|
||||
let endDate = parseDate(endStr, allDay: declaredAllDay)
|
||||
guard let s = startDate, let e = endDate else { return nil }
|
||||
|
||||
// An event running midnight to midnight across one or more whole days
|
||||
// is all-day whatever the payload claims. Without this, a six-day
|
||||
// holiday sent without the flag renders as "00:00 – 00:00" — a time
|
||||
// range that is technically true and tells the reader nothing.
|
||||
let cal = Calendar.current
|
||||
// Requiring the end to land exactly on midnight too was too strict: a
|
||||
// holiday stored as 16th 00:00 to 21st 01:00 slipped through and showed
|
||||
// up as a one-hour slot. Starting on a day boundary and running into a
|
||||
// later day is enough.
|
||||
let spansWholeDays = e > s
|
||||
&& cal.startOfDay(for: s) == s
|
||||
&& cal.startOfDay(for: e) > cal.startOfDay(for: s)
|
||||
let isAllDay = declaredAllDay || spansWholeDays
|
||||
|
||||
return CalEvent(
|
||||
id: id,
|
||||
url: json["url"] as? String ?? "",
|
||||
title: title,
|
||||
startDate: s,
|
||||
endDate: e,
|
||||
isAllDay: isAllDay,
|
||||
location: json["location"] as? String ?? "",
|
||||
notes: json["description"] as? String ?? "",
|
||||
color: (json["color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||
calendarId: json["calendar_id"].map { "\($0)" } ?? "",
|
||||
calendarName: json["calendar_name"] as? String ?? "",
|
||||
calendarColor: json["calendarColor"] as? String ?? "#4285f4",
|
||||
source: json["source"] as? String ?? "local",
|
||||
creator: EventPerson.from(json["creator"]),
|
||||
isPrivate: json["private"] as? Bool ?? false,
|
||||
owner: EventPerson.from(json["owner"]),
|
||||
isGroupEvent: json["is_group_event"] as? Bool ?? false,
|
||||
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [],
|
||||
readOnly: json["read_only"] as? Bool ?? false,
|
||||
isBirthday: json["is_birthday"] as? Bool ?? false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private let isoFull: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
private let isoBasic: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
private let dateOnly: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
f.timeZone = .current
|
||||
return f
|
||||
}()
|
||||
|
||||
// Handles all date formats the backend may produce:
|
||||
// "2026-05-17" "2026-05-17T10:00:00Z" "2026-05-17T10:00:00+02:00"
|
||||
// "2026-05-17T10:00:00.000Z" "2026-05-17T10:00:00" "2026-05-17 10:00:00+00:00"
|
||||
private let noTZFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
f.timeZone = TimeZone(abbreviation: "UTC")
|
||||
return f
|
||||
}()
|
||||
|
||||
private let spaceSepFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd HH:mm:ssZ"
|
||||
return f
|
||||
}()
|
||||
|
||||
func parseDate(_ s: String, allDay: Bool) -> Date? {
|
||||
let clean = s.trimmingCharacters(in: .whitespaces)
|
||||
if allDay || (clean.count == 10 && !clean.contains("T")) {
|
||||
return dateOnly.date(from: String(clean.prefix(10)))
|
||||
}
|
||||
// Try each formatter in order of likelihood
|
||||
if let d = isoFull.date(from: clean) { return d }
|
||||
if let d = isoBasic.date(from: clean) { return d }
|
||||
// Python isoformat uses space separator: "2026-05-17 10:00:00+00:00"
|
||||
if let d = spaceSepFormatter.date(from: clean) { return d }
|
||||
// No timezone → treat as UTC
|
||||
if let d = noTZFormatter.date(from: String(clean.prefix(19))) { return d }
|
||||
// Last resort: just parse the date part
|
||||
return dateOnly.date(from: String(clean.prefix(10)))
|
||||
}
|
||||
|
||||
func formatISO(_ date: Date, allDay: Bool) -> String {
|
||||
if allDay {
|
||||
return dateOnly.string(from: date)
|
||||
}
|
||||
return isoBasic.string(from: date)
|
||||
}
|
||||
|
||||
/// Inline label for an event in a calendar grid: a leading birthday (cake) icon
|
||||
/// when the event is a birthday, followed by the render title (which carries the
|
||||
/// server-computed age). Icon and text inherit the surrounding font/colour, so
|
||||
/// the label matches whatever bar it's dropped into.
|
||||
struct EventLabel: View {
|
||||
let event: CalEvent
|
||||
var body: some View {
|
||||
if event.isBirthday {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "birthday.cake.fill").imageScale(.small)
|
||||
Text(event.renderTitle)
|
||||
}
|
||||
} else {
|
||||
Text(event.renderTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import CalendarrCore
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted whenever the persistent "banished calendars" set is mutated from
|
||||
/// outside the active `CalendarStore` (e.g. by `AccountsView`). The store
|
||||
/// listens for this in `CalendarHostView` and refreshes its filter.
|
||||
static let banishedCalendarsChanged = Notification.Name("banishedCalendarsChanged")
|
||||
|
||||
/// Posted when the user taps the manual "sync with server" button in the
|
||||
/// menu. `CalendarHostView` responds by invalidating the cache and
|
||||
/// re-fetching events from the server.
|
||||
static let manualSyncRequested = Notification.Name("manualSyncRequested")
|
||||
}
|
||||
|
||||
enum CalViewType: String, CaseIterable {
|
||||
case month, week, day, quarter, agenda
|
||||
|
||||
func label(_ lang: String) -> String {
|
||||
switch self {
|
||||
case .month: return L10n.t("view.month", lang)
|
||||
case .week: return L10n.t("view.week", lang)
|
||||
case .day: return L10n.t("view.day", lang)
|
||||
case .quarter: return L10n.t("view.quarter", lang)
|
||||
case .agenda: return L10n.t("view.agenda", lang)
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .month: return "calendar"
|
||||
case .week: return "calendar.day.timeline.leading"
|
||||
case .day: return "sun.max"
|
||||
case .quarter: return "calendar.badge.clock"
|
||||
case .agenda: return "list.bullet"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WritableCalendar: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let color: String
|
||||
let source: String
|
||||
let numericId: Int
|
||||
}
|
||||
|
||||
@Observable
|
||||
class CalendarStore {
|
||||
// Visible state
|
||||
var events: [CalEvent] = []
|
||||
var viewType: CalViewType = .month
|
||||
var currentDate: Date = .now
|
||||
// The month currently scrolled into view (month view). Lives in the store so
|
||||
// the Liquid-Glass navigation title — read in the system toolbar — updates
|
||||
// via @Observable tracking (a plain @State did not refresh the toolbar).
|
||||
var visibleMonth: Date = .now
|
||||
var isLoading = false
|
||||
var isCachingBackground = false
|
||||
var lastError: String? = nil
|
||||
/// Per-calendar sync failures reported alongside the last successful
|
||||
/// personal `/events` fetch (e.g. expired CalDAV credentials on one
|
||||
/// account) — distinct from `lastError`, which means the whole fetch
|
||||
/// failed. Not populated in group-overlay mode. Left untouched on a hard
|
||||
/// fetch failure (see `loadEvents`), so a stale-but-real warning doesn't
|
||||
/// get wiped by an unrelated network hiccup.
|
||||
var syncErrors: [SyncError] = []
|
||||
var weekStartsOnMonday = true
|
||||
var writableCalendars: [WritableCalendar] = []
|
||||
// When set, the calendar shows the group's combined overlay instead of the
|
||||
// user's own events. nil = personal view.
|
||||
var activeGroup: CalGroup? = nil
|
||||
|
||||
/// Set of `"source:calendarId"` keys the user has chosen to hide from the
|
||||
/// calendar views. Persisted in UserDefaults as a JSON array. Events whose
|
||||
/// key matches one of these are filtered out before being rendered.
|
||||
var hiddenCalendarKeys: Set<String> = CalendarStore.loadHiddenKeys()
|
||||
|
||||
/// "Banished" calendars – like `hiddenCalendarKeys` but expressing a
|
||||
/// stronger user intent: the calendar should not even appear in the quick
|
||||
/// show/hide list. Re-activation happens in AccountsView.
|
||||
var banishedCalendarKeys: Set<String> = CalendarStore.loadBanishedKeys()
|
||||
|
||||
/// Set of `"source:calendarId"` keys whose events must NOT generate reminder
|
||||
/// notifications (mirrors the server `reminders_enabled=false` flag). Persisted
|
||||
/// in UserDefaults; reconciled from the server in the filter sheet.
|
||||
var reminderDisabledKeys: Set<String> = CalendarStore.loadReminderDisabledKeys()
|
||||
|
||||
/// Group-overlay visibility: which members' calendars (`gm:<userId>`) and the
|
||||
/// group calendar (`gc`) are hidden in the combined view — like hiding
|
||||
/// individual people in Outlook. In-memory; resets when leaving/switching a
|
||||
/// group (the per-calendar hide/banish sets are for the personal view only).
|
||||
var hiddenGroupKeys: Set<String> = []
|
||||
|
||||
static func groupMemberKey(_ ownerId: Int) -> String { "gm:\(ownerId)" }
|
||||
static let groupCalendarKey = "gc"
|
||||
|
||||
// Cache bookkeeping
|
||||
private var cachedStart: Date? = nil
|
||||
private var cachedEnd: Date? = nil
|
||||
private var allCachedEvents: [CalEvent] = []
|
||||
|
||||
// MARK: – Hidden-calendar persistence
|
||||
|
||||
private static let hiddenKeysDefaultsKey = "hiddenCalendarKeys"
|
||||
|
||||
private static func loadHiddenKeys() -> Set<String> {
|
||||
guard let raw = UserDefaults.standard.string(forKey: hiddenKeysDefaultsKey),
|
||||
let data = raw.data(using: .utf8),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data)
|
||||
else { return [] }
|
||||
return Set(arr)
|
||||
}
|
||||
|
||||
private func saveHiddenKeys() {
|
||||
let arr = Array(hiddenCalendarKeys)
|
||||
if let data = try? JSONEncoder().encode(arr),
|
||||
let s = String(data: data, encoding: .utf8) {
|
||||
UserDefaults.standard.set(s, forKey: Self.hiddenKeysDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle visibility of a single calendar and immediately refresh the
|
||||
/// visible event list + widget snapshot.
|
||||
func setCalendarHidden(_ key: String, hidden: Bool) {
|
||||
if hidden { hiddenCalendarKeys.insert(key) } else { hiddenCalendarKeys.remove(key) }
|
||||
saveHiddenKeys()
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Replace the entire set (used by the filter sheet's bulk show/hide).
|
||||
func setHiddenCalendars(_ keys: Set<String>) {
|
||||
hiddenCalendarKeys = keys
|
||||
saveHiddenKeys()
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Toggle / replace group-overlay visibility (members or the group calendar).
|
||||
func setGroupKeyHidden(_ key: String, hidden: Bool) {
|
||||
if hidden { hiddenGroupKeys.insert(key) } else { hiddenGroupKeys.remove(key) }
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
}
|
||||
|
||||
func setHiddenGroupKeys(_ keys: Set<String>) {
|
||||
hiddenGroupKeys = keys
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
}
|
||||
|
||||
static func calendarKey(source: String, calendarId: String) -> String {
|
||||
// The events API returns `calendar_id` inconsistently: a raw numeric for
|
||||
// CalDAV, but "<source>-<id>" for local / ical / google / homeassistant
|
||||
// (e.g. "local-3", "google-5"). The filter UI keys off the numeric DB id,
|
||||
// so strip any leading "<source>-" prefix to make event keys and filter
|
||||
// keys comparable — otherwise local hiding/banishing silently does nothing
|
||||
// for those sources.
|
||||
var id = calendarId
|
||||
let prefix = "\(source)-"
|
||||
if id.hasPrefix(prefix) { id = String(id.dropFirst(prefix.count)) }
|
||||
return "\(source):\(id)"
|
||||
}
|
||||
|
||||
// MARK: – Calendar order (device-local, mirrors the web `cal_order`)
|
||||
|
||||
private static let orderDefaultsKey = "calendarOrder"
|
||||
|
||||
/// Persisted display order of calendar keys ("source:id"). Device-local; the
|
||||
/// drawer's flat calendar list is sorted by this.
|
||||
private(set) var calendarOrder: [String] = CalendarStore.loadOrder()
|
||||
|
||||
private static func loadOrder() -> [String] {
|
||||
guard let raw = UserDefaults.standard.string(forKey: orderDefaultsKey),
|
||||
let data = raw.data(using: .utf8),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data)
|
||||
else { return [] }
|
||||
return arr
|
||||
}
|
||||
|
||||
private func saveOrder() {
|
||||
if let data = try? JSONEncoder().encode(calendarOrder),
|
||||
let s = String(data: data, encoding: .utf8) {
|
||||
UserDefaults.standard.set(s, forKey: Self.orderDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the stored order (after a drag-reorder).
|
||||
func setCalendarOrder(_ keys: [String]) {
|
||||
calendarOrder = keys
|
||||
saveOrder()
|
||||
}
|
||||
|
||||
/// Sort the given keys by the stored order (unknown keys → end), then persist
|
||||
/// the normalized order so newly-added calendars stick (mirrors the web).
|
||||
func ordered(_ keys: [String]) -> [String] {
|
||||
let current = calendarOrder
|
||||
func idx(_ key: String) -> Int { current.firstIndex(of: key) ?? Int.max }
|
||||
let sorted = keys.sorted { a, b in
|
||||
let ia = idx(a), ib = idx(b)
|
||||
return ia != ib ? ia < ib : a < b
|
||||
}
|
||||
calendarOrder = sorted
|
||||
saveOrder()
|
||||
return sorted
|
||||
}
|
||||
|
||||
// MARK: – Banished-calendar persistence
|
||||
|
||||
private static let banishedKeysDefaultsKey = "banishedCalendarKeys"
|
||||
|
||||
static func loadBanishedKeys() -> Set<String> {
|
||||
guard let raw = UserDefaults.standard.string(forKey: banishedKeysDefaultsKey),
|
||||
let data = raw.data(using: .utf8),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data)
|
||||
else { return [] }
|
||||
return Set(arr)
|
||||
}
|
||||
|
||||
static func saveBanishedKeys(_ keys: Set<String>) {
|
||||
let arr = Array(keys)
|
||||
if let data = try? JSONEncoder().encode(arr),
|
||||
let s = String(data: data, encoding: .utf8) {
|
||||
UserDefaults.standard.set(s, forKey: banishedKeysDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a calendar to / out of the banished set. Also clears any quick
|
||||
/// hidden flag for that key – once banished, the dual state is redundant.
|
||||
/// Posts `.banishedCalendarsChanged` so other views in the navigation
|
||||
/// stack (e.g. AccountsView) stay in sync.
|
||||
func setCalendarBanished(_ key: String, banished: Bool) {
|
||||
if banished {
|
||||
banishedCalendarKeys.insert(key)
|
||||
hiddenCalendarKeys.remove(key)
|
||||
} else {
|
||||
banishedCalendarKeys.remove(key)
|
||||
}
|
||||
Self.saveBanishedKeys(banishedCalendarKeys)
|
||||
saveHiddenKeys()
|
||||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Replace the whole banished set (used when reconciling with the server's
|
||||
/// `sidebar_hidden` flags). Persists, notifies, refreshes.
|
||||
func setBanishedCalendars(_ keys: Set<String>) {
|
||||
guard keys != banishedCalendarKeys else { return }
|
||||
banishedCalendarKeys = keys
|
||||
Self.saveBanishedKeys(keys)
|
||||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Re-read the banished set from UserDefaults – called when an external
|
||||
/// view (AccountsView) mutated it. Refreshes visible events + widgets.
|
||||
func syncBanishedFromDefaults() {
|
||||
banishedCalendarKeys = Self.loadBanishedKeys()
|
||||
let (s, e) = rangeForCurrentView()
|
||||
refreshFromCache(start: s, end: e)
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Reconcile the local "banished" set with the server's per-calendar
|
||||
/// `sidebar_hidden` flags (server wins for CalDAV / Google / HA). Returns
|
||||
/// `true` if the set changed, so the caller can force a refetch.
|
||||
///
|
||||
/// This closes the sync gap where hiding/showing a calendar on the web (or
|
||||
/// another device) was only ever picked up when the filter sheet or the
|
||||
/// accounts screen happened to be opened — not on app launch / resume. A
|
||||
/// calendar re-enabled on the web has NO events in the cache (the server
|
||||
/// excludes a hidden calendar's events entirely), so a plain
|
||||
/// `refreshFromCache` can't bring them back: the caller must force a reload.
|
||||
func reconcileCalendarVisibility(api: CalendarrAPI) async -> Bool {
|
||||
async let c = (try? await api.getCalDAVAccounts()) ?? []
|
||||
async let g = (try? await api.getGoogleAccounts()) ?? []
|
||||
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
||||
let (caldav, google, ha) = await (c, g, h)
|
||||
|
||||
var b = banishedCalendarKeys
|
||||
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
|
||||
let key = Self.calendarKey(source: source, calendarId: "\(id)")
|
||||
if hidden { b.insert(key) } else { b.remove(key) }
|
||||
}
|
||||
for acc in caldav { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
|
||||
for acc in google { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
|
||||
for acc in ha { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
|
||||
|
||||
guard b != banishedCalendarKeys else { return false }
|
||||
banishedCalendarKeys = b
|
||||
Self.saveBanishedKeys(b)
|
||||
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: – Reminder-disabled-calendar persistence
|
||||
|
||||
private static let reminderDisabledKeysDefaultsKey = "reminderDisabledCalendarKeys"
|
||||
|
||||
static func loadReminderDisabledKeys() -> Set<String> {
|
||||
guard let raw = UserDefaults.standard.string(forKey: reminderDisabledKeysDefaultsKey),
|
||||
let data = raw.data(using: .utf8),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data)
|
||||
else { return [] }
|
||||
return Set(arr)
|
||||
}
|
||||
|
||||
static func saveReminderDisabledKeys(_ keys: Set<String>) {
|
||||
if let data = try? JSONEncoder().encode(Array(keys)),
|
||||
let s = String(data: data, encoding: .utf8) {
|
||||
UserDefaults.standard.set(s, forKey: reminderDisabledKeysDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle whether a single calendar's events generate reminders, then
|
||||
/// reschedule notifications from the cache.
|
||||
func setReminderDisabled(_ key: String, disabled: Bool) {
|
||||
if disabled { reminderDisabledKeys.insert(key) } else { reminderDisabledKeys.remove(key) }
|
||||
Self.saveReminderDisabledKeys(reminderDisabledKeys)
|
||||
rescheduleNotifications()
|
||||
}
|
||||
|
||||
/// Replace the whole set (used when reconciling with the server's
|
||||
/// `reminders_enabled` flags in the filter sheet).
|
||||
func setReminderDisabledKeys(_ keys: Set<String>) {
|
||||
guard keys != reminderDisabledKeys else { return }
|
||||
reminderDisabledKeys = keys
|
||||
Self.saveReminderDisabledKeys(keys)
|
||||
rescheduleNotifications()
|
||||
}
|
||||
|
||||
/// Split a `"source:calendarId"` key back into its parts.
|
||||
static func parseCalendarKey(_ key: String) -> (source: String, id: Int)? {
|
||||
guard let colon = key.firstIndex(of: ":") else { return nil }
|
||||
let source = String(key[..<colon])
|
||||
guard let id = Int(key[key.index(after: colon)...]) else { return nil }
|
||||
return (source, id)
|
||||
}
|
||||
|
||||
/// Sources whose visibility is backed by the server's `sidebar_hidden`.
|
||||
static let serverManagedSources: Set<String> = ["caldav", "google", "homeassistant"]
|
||||
|
||||
var userCalendar: Calendar {
|
||||
var cal = Calendar.current
|
||||
cal.firstWeekday = weekStartsOnMonday ? 2 : 1
|
||||
return cal
|
||||
}
|
||||
|
||||
// MARK: – Cache helpers
|
||||
|
||||
func isCached(start: Date, end: Date) -> Bool {
|
||||
guard let cs = cachedStart, let ce = cachedEnd else { return false }
|
||||
return cs <= start && ce >= end
|
||||
}
|
||||
|
||||
/// Republish the full cached event set, applying only visibility filters
|
||||
/// (hidden + banished). We deliberately do NOT slice by the current view's
|
||||
/// date window: the user's chosen cache range is already loaded, and
|
||||
/// scrolling within it must not make events vanish. Per-day / per-range
|
||||
/// rendering is the responsibility of `events(on:)` / `events(in:)`.
|
||||
/// `start` / `end` are kept in the signature for call-site clarity.
|
||||
func refreshFromCache(start: Date, end: Date) {
|
||||
_ = (start, end)
|
||||
// In group overlay mode the per-calendar hide/banish toggles don't apply;
|
||||
// instead honour the per-member / group-calendar toggles (hiddenGroupKeys).
|
||||
if activeGroup != nil {
|
||||
if hiddenGroupKeys.isEmpty {
|
||||
events = allCachedEvents
|
||||
} else {
|
||||
events = allCachedEvents.filter { ev in
|
||||
if ev.isGroupEvent { return !hiddenGroupKeys.contains(Self.groupCalendarKey) }
|
||||
if let o = ev.owner { return !hiddenGroupKeys.contains(Self.groupMemberKey(o.id ?? -1)) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
events = allCachedEvents.filter { ev in
|
||||
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||
return !hiddenCalendarKeys.contains(key)
|
||||
&& !banishedCalendarKeys.contains(key)
|
||||
}
|
||||
// Personal events drive local reminder notifications.
|
||||
NotificationScheduler.reschedule(events: allCachedEvents, disabledCalendarKeys: reminderDisabledKeys)
|
||||
}
|
||||
|
||||
/// Recompute scheduled reminder notifications from the personal cache
|
||||
/// (skipped while a group overlay is active).
|
||||
func rescheduleNotifications() {
|
||||
guard activeGroup == nil else { return }
|
||||
NotificationScheduler.reschedule(events: allCachedEvents, disabledCalendarKeys: reminderDisabledKeys)
|
||||
}
|
||||
|
||||
/// Optimistically drop a just-deleted event from the cache so it disappears
|
||||
/// from the UI immediately, without waiting for a server round-trip (HA
|
||||
/// deletes can lag several seconds, and an immediate refetch could even
|
||||
/// re-add it before the source propagated the deletion).
|
||||
func removeCachedEvent(id: String) {
|
||||
allCachedEvents.removeAll { $0.id == id }
|
||||
events.removeAll { $0.id == id }
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
// MARK: – Network loading
|
||||
|
||||
/// Load events for a specific range. Skips the network if already cached,
|
||||
/// unless `force` is set (used after create/edit to pull fresh server data
|
||||
/// for the visible range, bypassing the cache).
|
||||
func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async {
|
||||
if !force, isCached(start: start, end: end) {
|
||||
refreshFromCache(start: start, end: end)
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
lastError = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end)
|
||||
syncErrors = errors
|
||||
let failed = failedCalendarKeys(from: errors)
|
||||
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end,
|
||||
keepKeysInRange: failed.keys, keepSourcesInRange: failed.sources)
|
||||
refreshFromCache(start: start, end: end)
|
||||
} catch {
|
||||
// Hard failure – leave `syncErrors` as-is; it reflects the last
|
||||
// *successful* fetch and shouldn't be wiped by an unrelated
|
||||
// network error on this attempt.
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch events for the current mode (personal vs. group overlay). Group
|
||||
/// events go through the same cache/prefetch/refresh path as personal ones,
|
||||
/// so the whole visible grid is covered (no "only the middle weeks" gaps).
|
||||
/// Per-calendar sync errors are only reported by the personal `/events`
|
||||
/// endpoint; group overlays always report none.
|
||||
private func fetchForMode(api: CalendarrAPI, start: Date, end: Date) async throws -> (events: [CalEvent], errors: [SyncError]) {
|
||||
if let g = activeGroup {
|
||||
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
|
||||
return (combined.map { decorateGroupEvent($0) }, [])
|
||||
}
|
||||
return try await api.fetchEvents(start: start, end: end)
|
||||
}
|
||||
|
||||
/// Prefix a combined-view event with its owner (others) or 👥 + creator
|
||||
/// (group calendar). Colour comes from the server's display_color.
|
||||
private func decorateGroupEvent(_ ev: CalEvent) -> CalEvent {
|
||||
// Prefer the server-decorated title (group icon + owner prefix) so web,
|
||||
// iOS and Android render group events identically. `title` stays raw.
|
||||
if let dt = ev.displayTitle, !dt.isEmpty {
|
||||
var e = ev
|
||||
e.title = dt
|
||||
return e
|
||||
}
|
||||
// Fallback for older servers without display_title.
|
||||
var e = ev
|
||||
let me = UserDefaults.standard.integer(forKey: "userId")
|
||||
let groupIcon = activeGroup?.icon ?? "👥"
|
||||
func first(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s }
|
||||
if ev.isGroupEvent {
|
||||
if let c = ev.creator, c.id != me { e.title = "\(groupIcon) \(first(c.displayName)): \(ev.title)" }
|
||||
else { e.title = "\(groupIcon) \(ev.title)" }
|
||||
} else if let o = ev.owner, o.id != me {
|
||||
e.title = "\(first(o.displayName)): \(ev.title)"
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
/// Background prefetch for ±months around today – called once on startup.
|
||||
func prefetchBackground(api: CalendarrAPI, months: Int) async {
|
||||
let cal = userCalendar
|
||||
let now = Date()
|
||||
let start = cal.date(byAdding: .month, value: -months, to: cal.startOfDay(for: now))!
|
||||
let end = cal.date(byAdding: .month, value: months + 1, to: cal.startOfDay(for: now))!
|
||||
guard !isCached(start: start, end: end) else { return }
|
||||
|
||||
isCachingBackground = true
|
||||
defer { isCachingBackground = false }
|
||||
do {
|
||||
let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end)
|
||||
syncErrors = errors
|
||||
let failed = failedCalendarKeys(from: errors)
|
||||
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end,
|
||||
keepKeysInRange: failed.keys, keepSourcesInRange: failed.sources)
|
||||
// Refresh visible range from newly expanded cache
|
||||
let (vs, ve) = rangeForCurrentView()
|
||||
refreshFromCache(start: vs, end: ve)
|
||||
} catch {
|
||||
// Background fetch failure is silent
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a full cache reload (e.g. when cache-range setting changes).
|
||||
/// Intentionally keeps `events` intact so the UI stays populated while
|
||||
/// the network fetch runs; `refreshFromCache` will swap in fresh data
|
||||
/// atomically once it arrives.
|
||||
func invalidateCache() {
|
||||
cachedStart = nil
|
||||
cachedEnd = nil
|
||||
allCachedEvents = []
|
||||
}
|
||||
|
||||
/// Calendars / sources that reported a sync error on the last fetch. Events
|
||||
/// matching either must NOT be evicted from the cache on a partial sync —
|
||||
/// stale data beats a completely empty calendar.
|
||||
///
|
||||
/// - `keys`: per-calendar errors carrying a `calendar_id` (e.g. one CalDAV
|
||||
/// calendar with bad credentials) → protect just that calendar.
|
||||
/// - `sources`: account-level errors the server couldn't pin to a single
|
||||
/// calendar → protect every cached calendar of that source. The
|
||||
/// key example is a Home Assistant / Google token-refresh
|
||||
/// failure: it aborts the whole account fetch, so the error
|
||||
/// arrives with no `calendar_id` and every HA calendar would
|
||||
/// otherwise be wiped.
|
||||
private func failedCalendarKeys(from errors: [SyncError]) -> (keys: Set<String>, sources: Set<String>) {
|
||||
var keys = Set<String>()
|
||||
var sources = Set<String>()
|
||||
for err in errors {
|
||||
if let cid = err.calendarId {
|
||||
keys.insert(Self.calendarKey(source: err.source, calendarId: cid))
|
||||
} else {
|
||||
sources.insert(err.source)
|
||||
}
|
||||
}
|
||||
return (keys, sources)
|
||||
}
|
||||
|
||||
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date,
|
||||
keepKeysInRange: Set<String> = [],
|
||||
keepSourcesInRange: Set<String> = []) {
|
||||
// Remove old events in the fetched range to avoid duplicates — but
|
||||
// PRESERVE events from calendars / sources that had sync errors so that a
|
||||
// transient CalDAV / Google / HA failure doesn't wipe the visible calendar.
|
||||
let retained = allCachedEvents.filter { ev in
|
||||
let outsideRange = ev.startDate >= rangeEnd || ev.endDate <= rangeStart
|
||||
if outsideRange { return true }
|
||||
if keepSourcesInRange.contains(ev.source) { return true }
|
||||
guard !keepKeysInRange.isEmpty else { return false }
|
||||
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||
return keepKeysInRange.contains(key)
|
||||
}
|
||||
allCachedEvents = retained + newEvents
|
||||
|
||||
// Only extend the cached range when the fetch was completely clean.
|
||||
// When some calendars/sources had sync errors, leave cachedStart/End
|
||||
// unchanged: this keeps isCached() returning false for the next
|
||||
// loadEvents call so the failed calendars are retried automatically —
|
||||
// rather than being silently treated as "done" with empty data
|
||||
// (especially important on first launch or after forceReload).
|
||||
guard keepKeysInRange.isEmpty, keepSourcesInRange.isEmpty else { return }
|
||||
if let cs = cachedStart, let ce = cachedEnd {
|
||||
cachedStart = min(cs, rangeStart)
|
||||
cachedEnd = max(ce, rangeEnd)
|
||||
} else {
|
||||
cachedStart = rangeStart
|
||||
cachedEnd = rangeEnd
|
||||
}
|
||||
|
||||
publishWidgetSnapshot()
|
||||
}
|
||||
|
||||
/// Write a slim snapshot of the next ~6 weeks into the App-Group container
|
||||
/// so the widget extension can render without a network call. 42 days
|
||||
/// covers the worst-case month grid (6 rows × 7 cols) for the calendar
|
||||
/// widget. Also asks the system to refresh the widget timeline.
|
||||
private func publishWidgetSnapshot() {
|
||||
// Never write group-view data into the widget; it would show other
|
||||
// people's calendar colours and decorated event titles.
|
||||
guard activeGroup == nil else { return }
|
||||
|
||||
let cal = userCalendar
|
||||
let now = Date()
|
||||
// Include the week before today so widgets that show the current week
|
||||
// (e.g. "This Week", "Up Next + Calendar") have data for Monday–today.
|
||||
// The window is published in the snapshot as coverageStart/coverageEnd:
|
||||
// outside it a reader has no information, which is not the same as
|
||||
// having no events, and only the writer knows where the edge is.
|
||||
let dayStart = cal.startOfDay(for: now)
|
||||
let from = cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart) ?? now
|
||||
let to = cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart) ?? from
|
||||
|
||||
// Build the calendar list from all cached events (not just the window)
|
||||
// so every calendar appears in the widget configuration picker.
|
||||
var calendarMap: [String: WidgetCalendar] = [:]
|
||||
for ev in allCachedEvents {
|
||||
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||
guard !banishedCalendarKeys.contains(key) else { continue }
|
||||
if calendarMap[key] == nil {
|
||||
calendarMap[key] = WidgetCalendar(id: key,
|
||||
name: ev.calendarName.isEmpty ? ev.calendarId : ev.calendarName,
|
||||
colorHex: ev.calendarColor)
|
||||
}
|
||||
}
|
||||
let calendars = Array(calendarMap.values).sorted { $0.name < $1.name }
|
||||
WidgetStore.writeCalendars(calendars)
|
||||
|
||||
let visible = allCachedEvents
|
||||
.filter { ev in
|
||||
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||
return ev.startDate < to && ev.endDate > from
|
||||
&& !hiddenCalendarKeys.contains(key)
|
||||
&& !banishedCalendarKeys.contains(key)
|
||||
}
|
||||
.sorted { $0.startDate < $1.startDate }
|
||||
.prefix(SnapshotCoverage.maxEvents)
|
||||
.map { ev in
|
||||
WidgetEvent(id: ev.id,
|
||||
title: ev.title,
|
||||
start: ev.startDate,
|
||||
end: ev.endDate,
|
||||
isAllDay: ev.isAllDay,
|
||||
colorHex: ev.effectiveColor,
|
||||
location: ev.location,
|
||||
calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId))
|
||||
}
|
||||
let snapshot = WidgetStore.makeSnapshot(events: Array(visible),
|
||||
coverageStart: from,
|
||||
coverageEnd: to)
|
||||
WidgetStore.write(snapshot)
|
||||
WidgetTimelineNotifier.reload()
|
||||
// The watch has its own container and cannot read the file we just
|
||||
// wrote, so it is handed the data explicitly.
|
||||
WatchSyncService.shared.push(snapshot: snapshot, calendars: calendars)
|
||||
}
|
||||
|
||||
// MARK: – Writable calendars
|
||||
|
||||
func loadWritableCalendars(api: CalendarrAPI) async {
|
||||
async let localCals = (try? await api.getLocalCalendars()) ?? []
|
||||
async let caldavAccs = (try? await api.getCalDAVAccounts()) ?? []
|
||||
async let googleCals = (try? await api.getGoogleCalendars()) ?? []
|
||||
async let haCals = (try? await api.getHACalendars()) ?? []
|
||||
|
||||
var result: [WritableCalendar] = []
|
||||
// Skip read-only shared calendars — offering them in the event editor
|
||||
// only leads to a 403 on save. Own + read_write (incl. group) stay.
|
||||
for cal in await localCals where cal.owned || cal.permission == "read_write" {
|
||||
result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id))
|
||||
}
|
||||
for acc in await caldavAccs where acc.enabled {
|
||||
for cal in acc.calendars ?? [] where cal.enabled {
|
||||
result.append(WritableCalendar(id: "caldav-\(cal.id)", name: "\(acc.name) – \(cal.name)", color: cal.color ?? acc.color, source: "caldav", numericId: cal.id))
|
||||
}
|
||||
}
|
||||
for (email, id, name, color) in await googleCals {
|
||||
result.append(WritableCalendar(id: "google-\(id)", name: "\(email) – \(name)", color: color, source: "google", numericId: id))
|
||||
}
|
||||
for (accName, id, name, color) in await haCals {
|
||||
result.append(WritableCalendar(id: "ha-\(id)", name: "\(accName) – \(name)", color: color, source: "homeassistant", numericId: id))
|
||||
}
|
||||
writableCalendars = result
|
||||
}
|
||||
|
||||
// MARK: – Query helpers
|
||||
|
||||
func events(on date: Date) -> [CalEvent] {
|
||||
let cal = userCalendar
|
||||
let dayStart = cal.startOfDay(for: date)
|
||||
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
|
||||
return events.filter { ev in ev.startDate < dayEnd && ev.endDate > dayStart }
|
||||
.sorted { $0.startDate < $1.startDate }
|
||||
}
|
||||
|
||||
func events(in start: Date, end: Date) -> [CalEvent] {
|
||||
events.filter { ev in ev.startDate < end && ev.endDate > start }
|
||||
.sorted { $0.startDate < $1.startDate }
|
||||
}
|
||||
|
||||
// MARK: – Navigation
|
||||
|
||||
func moveToToday() { currentDate = .now }
|
||||
|
||||
func navigatePrev() {
|
||||
currentDate = userCalendar.date(byAdding: navComponent, value: navAmount * -1, to: currentDate) ?? currentDate
|
||||
}
|
||||
|
||||
func navigateNext() {
|
||||
currentDate = userCalendar.date(byAdding: navComponent, value: navAmount, to: currentDate) ?? currentDate
|
||||
}
|
||||
|
||||
private var navComponent: Calendar.Component {
|
||||
switch viewType {
|
||||
case .week: return .weekOfYear
|
||||
case .day: return .day
|
||||
default: return .month
|
||||
}
|
||||
}
|
||||
private var navAmount: Int { viewType == .quarter ? 3 : 1 }
|
||||
|
||||
func rangeForCurrentView() -> (Date, Date) {
|
||||
let cal = userCalendar
|
||||
switch viewType {
|
||||
case .month:
|
||||
let start = cal.date(from: cal.dateComponents([.year, .month], from: currentDate))!
|
||||
return (cal.date(byAdding: .month, value: -1, to: start)!,
|
||||
cal.date(byAdding: .month, value: 2, to: start)!)
|
||||
case .quarter:
|
||||
let start = cal.date(from: cal.dateComponents([.year, .month], from: currentDate))!
|
||||
return (start, cal.date(byAdding: .month, value: 4, to: start)!)
|
||||
case .week:
|
||||
let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: currentDate))!
|
||||
return (weekStart, cal.date(byAdding: .day, value: 8, to: weekStart)!)
|
||||
case .day:
|
||||
let dayStart = cal.startOfDay(for: currentDate)
|
||||
return (dayStart, cal.date(byAdding: .day, value: 1, to: dayStart)!)
|
||||
case .agenda:
|
||||
let start = cal.startOfDay(for: .now)
|
||||
return (start, cal.date(byAdding: .day, value: 90, to: start)!)
|
||||
}
|
||||
}
|
||||
|
||||
func titleForCurrentView(language: String) -> String {
|
||||
let cal = userCalendar
|
||||
let loc = L10n.locale(language)
|
||||
let fmt = DateFormatter(); fmt.locale = loc
|
||||
switch viewType {
|
||||
case .month:
|
||||
fmt.dateFormat = "LLLL yyyy"
|
||||
return fmt.string(from: currentDate).capitalized(with: loc)
|
||||
case .quarter:
|
||||
fmt.dateFormat = "LLL yyyy"
|
||||
let m3 = cal.date(byAdding: .month, value: 2, to: currentDate) ?? currentDate
|
||||
return "\(fmt.string(from: currentDate)) – \(fmt.string(from: m3))"
|
||||
case .week:
|
||||
let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: currentDate))!
|
||||
let weekEnd = cal.date(byAdding: .day, value: 6, to: weekStart)!
|
||||
fmt.dateFormat = "d. MMM"
|
||||
let ef = DateFormatter(); ef.locale = loc; ef.dateFormat = "d. MMM yyyy"
|
||||
return "\(fmt.string(from: weekStart)) – \(ef.string(from: weekEnd))"
|
||||
case .day:
|
||||
fmt.dateFormat = "EEEE, d. MMMM yyyy"
|
||||
return fmt.string(from: currentDate)
|
||||
case .agenda:
|
||||
return L10n.t("view.agenda", language)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum AppLanguage: String, CaseIterable {
|
||||
case system, de, en
|
||||
|
||||
var displayKey: String {
|
||||
switch self {
|
||||
case .system: return "lang.system"
|
||||
case .de: return "lang.german"
|
||||
case .en: return "lang.english"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum L10n {
|
||||
static func resolved(_ stored: String) -> String {
|
||||
if stored == "de" || stored == "en" { return stored }
|
||||
let pref = Locale.preferredLanguages.first ?? "en"
|
||||
return pref.lowercased().hasPrefix("de") ? "de" : "en"
|
||||
}
|
||||
|
||||
static func t(_ key: String, _ stored: String) -> String {
|
||||
let lang = resolved(stored)
|
||||
return strings[lang]?[key] ?? strings["en"]?[key] ?? key
|
||||
}
|
||||
|
||||
static func locale(_ stored: String) -> Locale {
|
||||
Locale(identifier: resolved(stored))
|
||||
}
|
||||
}
|
||||
|
||||
private let strings: [String: [String: String]] = [
|
||||
"de": [
|
||||
// Top bar / navigation
|
||||
"nav.today": "Heute",
|
||||
"nav.menu": "Menü",
|
||||
"nav.done": "Fertig",
|
||||
|
||||
// View types
|
||||
"view.month": "Monat",
|
||||
"view.week": "Woche",
|
||||
"view.day": "Tag",
|
||||
"view.quarter": "Quartal",
|
||||
"view.agenda": "Termine",
|
||||
"view.change": "Ansicht",
|
||||
|
||||
// Calendar misc
|
||||
"cal.cw": "KW",
|
||||
"cal.allday": "Ganztägig",
|
||||
"cal.no_events_title": "Keine Termine",
|
||||
"cal.no_events_body": "In den nächsten 90 Tagen sind keine Termine vorhanden.",
|
||||
"cal.loading_more": "Lade weitere Wochen…",
|
||||
"cal.new_event": "Neues Ereignis",
|
||||
"cal.show_in_day_view": "In Tagesansicht öffnen",
|
||||
"cal.show_in_week_view": "In Wochenansicht öffnen",
|
||||
"cal.show_in_month_view": "In Monatsansicht öffnen",
|
||||
|
||||
// Menu sheet
|
||||
"menu.section.settings": "Einstellungen",
|
||||
"menu.profile": "Profil",
|
||||
"menu.appearance": "Darstellung",
|
||||
"menu.accounts": "Konten & Kalender",
|
||||
"menu.server": "Server",
|
||||
"menu.logout": "Abmelden",
|
||||
"menu.admin": "Admin",
|
||||
"menu.sync": "Mit Server synchronisieren",
|
||||
"menu.sync.section": "Synchronisierung",
|
||||
|
||||
// Settings – chrome
|
||||
"settings.title": "Darstellung",
|
||||
"settings.loading": "Lade Einstellungen…",
|
||||
"settings.save": "Speichern",
|
||||
"settings.saved": "Gespeichert",
|
||||
|
||||
// Settings – sections
|
||||
"settings.appdesign": "App-Design",
|
||||
"settings.liquidglass": "Liquid Glass",
|
||||
"settings.liquidglass.desc": "Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste",
|
||||
"settings.liquidglass.footer": "Änderung wirkt sofort – kein Neustart nötig.",
|
||||
"settings.sync": "Einstellungen synchronisieren",
|
||||
"settings.sync.desc": "Darstellung mit dem Server abgleichen",
|
||||
"settings.sync.footer": "Wenn aktiv, werden Farben, Kontraste und Stundenhöhe mit dem Server abgeglichen (der Server hat Vorrang). Ansicht, erster Wochentag und das Ausgrauen vergangener Termine werden immer synchronisiert – auch wenn der Schalter aus ist.",
|
||||
"settings.sync_all": "Alle synchronisieren",
|
||||
"settings.sync_all.desc": "Diese Einstellungen zwischen deinen Geräten teilen",
|
||||
"settings.sync_this": "Zwischen Geräten synchronisieren",
|
||||
"settings.reset": "Zurücksetzen",
|
||||
"settings.appearance": "Ansicht",
|
||||
"settings.color.surface": "Topbar-/Oberflächenfarbe",
|
||||
"settings.surface.auto": "Auto (Milchglas)",
|
||||
"settings.device": "Nur auf diesem Gerät",
|
||||
"settings.hide_menu_button": "Menü-Button ausblenden",
|
||||
"settings.device.footer": "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.",
|
||||
|
||||
"settings.cache.header": "Vorladen",
|
||||
"settings.cache.title": "Vorladen",
|
||||
"settings.cache.desc": "Events werden beim Start im Hintergrund für diesen Zeitraum geladen, danach ist Wischen sofort.",
|
||||
"settings.cache.range": "Zeitraum",
|
||||
"settings.cache.1m": "±1 Monat",
|
||||
"settings.cache.3m": "±3 Monate",
|
||||
"settings.cache.6m": "±6 Monate",
|
||||
"settings.cache.1y": "±1 Jahr",
|
||||
"settings.cache.footer": "Mehr Monate = längerer initialer Ladevorgang, danach komplett ohne Wartezeiten navigierbar.",
|
||||
|
||||
"settings.language": "Sprache",
|
||||
"lang.system": "Systemstandard",
|
||||
"lang.german": "Deutsch",
|
||||
"lang.english": "English",
|
||||
|
||||
"settings.colors": "Farben",
|
||||
"settings.color.primary": "Primärfarbe",
|
||||
"settings.color.accent": "Akzentfarbe",
|
||||
"settings.color.today": "Heutige-Tag-Farbe",
|
||||
"settings.color.divider": "Monatswechsel-Linie",
|
||||
"settings.color.label": "Monatskürzel",
|
||||
"settings.color.text": "Schriftfarbe",
|
||||
"settings.color.background": "Hintergrundfarbe",
|
||||
"settings.color.line": "Linienfarbe",
|
||||
|
||||
"settings.textcontrast": "Schriftkontrast",
|
||||
"settings.textcontrast.desc": "Helligkeit der Beschriftungen und Texte",
|
||||
"settings.contrast.dark": "Dunkel",
|
||||
"settings.contrast.medium": "Mittel",
|
||||
"settings.contrast.bright": "Hell",
|
||||
"settings.contrast.max": "Maximum",
|
||||
|
||||
"settings.linecontrast": "Linienkontrast",
|
||||
"settings.linecontrast.desc": "Sichtbarkeit von Trennlinien und Rahmen",
|
||||
"settings.linecontrast.barely": "Kaum",
|
||||
"settings.linecontrast.subtle": "Subtil",
|
||||
"settings.linecontrast.normal": "Normal",
|
||||
"settings.linecontrast.strong": "Stark",
|
||||
|
||||
"settings.calview": "Kalenderansicht",
|
||||
"settings.defaultview": "Standardansicht",
|
||||
"settings.firstweekday": "Erster Wochentag",
|
||||
"settings.monday": "Montag",
|
||||
"settings.sunday": "Sonntag",
|
||||
"settings.dimpast": "Vergangene Termine ausgrauen",
|
||||
"settings.month_paged": "Monatsansicht seitenweise wischen",
|
||||
"settings.month_mode": "Monatswechsel",
|
||||
"settings.month_mode.scroll": "Fortlaufend scrollen",
|
||||
"settings.month_mode.paged": "Seitenweise blättern",
|
||||
"settings.default_duration": "Standard-Termindauer",
|
||||
"settings.nav.profile": "Profil",
|
||||
"settings.privacy": "Privatsphäre",
|
||||
"settings.private_visibility": "Private Termine für Gruppen",
|
||||
"settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen",
|
||||
"settings.directory_hidden": "Profil verbergen",
|
||||
"settings.directory_hidden.desc": "Nicht in Teilen-/Gruppen-Auswahl anzeigen. In der Admin-Benutzerverwaltung bleibst du sichtbar.",
|
||||
"settings.private.busy": "Als „Beschäftigt“",
|
||||
"settings.private.hidden": "Ausblenden",
|
||||
"settings.calendars": "Geteilter Kalender",
|
||||
"settings.group_visible": "Für Gruppen sichtbar",
|
||||
"settings.group_visible.desc": "Wähle, welcher deiner Kalender für Gruppenmitglieder sichtbar ist",
|
||||
"group.visible.none": "Keiner",
|
||||
"profile.display_name": "Anzeigename",
|
||||
"profile.login_name": "Login-Name",
|
||||
"accounts.shared_by": "geteilt von %@",
|
||||
"share.title": "Teilen",
|
||||
"share.current": "Aktuelle Freigaben",
|
||||
"share.none": "Noch nicht geteilt",
|
||||
"share.add": "Benutzer hinzufügen",
|
||||
"share.search": "Benutzer suchen…",
|
||||
"share.permission": "Berechtigung",
|
||||
"perm.read": "Nur lesen",
|
||||
"perm.read_write": "Lesen & schreiben",
|
||||
"ics.import": "Importieren",
|
||||
"ics.export": "Exportieren",
|
||||
"ics.import_result": "%d importiert, %d übersprungen",
|
||||
"common.info": "Info",
|
||||
"common.done": "Fertig",
|
||||
"groups.title": "Gruppen",
|
||||
"groups.personal": "Persönlich",
|
||||
"groups.view_label": "Gruppenansicht",
|
||||
"groups.exit": "Verlassen",
|
||||
"groups.none": "Noch keine Gruppen",
|
||||
"groups.combined_empty": "Keine Termine in diesem Zeitraum",
|
||||
"group.create": "Gruppe erstellen",
|
||||
"group.manage": "Gruppe verwalten",
|
||||
"group.name": "Name",
|
||||
"group.icon": "Icon",
|
||||
"group.members": "Mitglieder",
|
||||
"group.calendar": "Gruppenkalender",
|
||||
"group.member_colors": "Farben der Mitglieder",
|
||||
"group.delete": "Gruppe löschen",
|
||||
|
||||
"settings.hourheight": "Stundenhöhe",
|
||||
"settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht",
|
||||
"settings.hourheight.compact": "Kompakt",
|
||||
"settings.hourheight.normal": "Normal",
|
||||
"settings.hourheight.comfort": "Komfort",
|
||||
"settings.hourheight.large": "Gross",
|
||||
|
||||
// Common buttons
|
||||
"common.cancel": "Abbrechen",
|
||||
"common.close": "Schliessen",
|
||||
"common.ok": "OK",
|
||||
"common.error": "Fehler",
|
||||
|
||||
// Server view
|
||||
"server.title": "Server",
|
||||
"server.connected": "Verbundener Server",
|
||||
"server.switch": "Server wechseln",
|
||||
"server.switch_msg": "Verbindung zu %@ wird getrennt und alle lokalen Anmeldedaten werden gelöscht.",
|
||||
"server.logout_title": "Abmelden",
|
||||
"server.logout_msg": "Du wirst von %@ abgemeldet.",
|
||||
"server.info": "Info",
|
||||
"server.imprint": "Impressum",
|
||||
"server.version": "Version",
|
||||
|
||||
// Imprint
|
||||
"imprint.company": "Scarriffleservices",
|
||||
"imprint.role": "Software & Webentwicklung",
|
||||
"imprint.copyright": "Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.",
|
||||
"imprint.storage.title": "Datenspeicherung",
|
||||
"imprint.storage.body": "Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.",
|
||||
"imprint.disclaimer.title": "Haftungsausschluss",
|
||||
"imprint.disclaimer.body": "Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.",
|
||||
"imprint.contact.title": "Kontakt",
|
||||
|
||||
// Profile view
|
||||
"profile.title": "Profil",
|
||||
"profile.loading": "Lade Profil…",
|
||||
"profile.account": "Konto",
|
||||
"profile.username": "Benutzername",
|
||||
"profile.role": "Rolle",
|
||||
"profile.role.admin": "Administrator",
|
||||
"profile.role.user": "Benutzer",
|
||||
"profile.email": "E-Mail",
|
||||
"profile.no_email": "Keine E-Mail",
|
||||
"profile.save_email": "E-Mail speichern",
|
||||
"profile.email_saved": "E-Mail gespeichert",
|
||||
"profile.change_password": "Passwort ändern",
|
||||
"profile.current_password": "Aktuelles Passwort",
|
||||
"profile.new_password": "Neues Passwort",
|
||||
"profile.new_password_repeat": "Neues Passwort wiederholen",
|
||||
"profile.password_mismatch": "Passwörter stimmen nicht überein",
|
||||
"profile.password_changed": "Passwort geändert",
|
||||
"profile.twofa": "Zwei-Faktor-Authentifizierung",
|
||||
"profile.twofa.active": "2FA ist aktiviert",
|
||||
"profile.twofa.inactive": "2FA ist deaktiviert",
|
||||
"profile.twofa.enable": "2FA einrichten",
|
||||
"profile.twofa.disable": "2FA deaktivieren",
|
||||
"profile.twofa.enabled_toast": "2FA aktiviert",
|
||||
"profile.twofa.disabled_toast": "2FA deaktiviert",
|
||||
"twofa.setup_title": "2FA einrichten",
|
||||
"twofa.scan_hint": "Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).",
|
||||
"twofa.qr_section": "QR-Code / Manueller Schlüssel",
|
||||
"twofa.confirmation": "Bestätigung",
|
||||
"twofa.code_placeholder": "6-stelliger Code",
|
||||
"twofa.activate": "Aktivieren",
|
||||
"twofa.disable_title": "2FA deaktivieren",
|
||||
"twofa.password_section": "Passwort zum Deaktivieren",
|
||||
"twofa.password_placeholder": "Passwort",
|
||||
"twofa.disable": "Deaktivieren",
|
||||
|
||||
// Event editor
|
||||
"event.title_placeholder": "Titel",
|
||||
"event.allday": "Ganztägig",
|
||||
"event.private": "Privat",
|
||||
"event.start": "Start",
|
||||
"event.end": "Ende",
|
||||
"event.location": "Ort",
|
||||
"event.description": "Beschreibung",
|
||||
"event.calendar_section": "Kalender",
|
||||
"event.no_writable": "Keine beschreibbaren Kalender vorhanden",
|
||||
"event.calendar_picker": "Kalender",
|
||||
"event.color_section": "Farbe",
|
||||
"event.color": "Terminfarbe",
|
||||
"event.reset_color": "Zurücksetzen",
|
||||
"event.edit_title": "Termin bearbeiten",
|
||||
"event.new_title": "Neuer Termin",
|
||||
"event.copy_title": "Termin kopieren",
|
||||
"event.copy_to": "In Kalender kopieren",
|
||||
"event.save": "Sichern",
|
||||
"event.add": "Hinzufügen",
|
||||
|
||||
// Event detail
|
||||
"detail.title": "Termin",
|
||||
"detail.source": "Quelle",
|
||||
"detail.created_by": "Erstellt von",
|
||||
"detail.delete": "Termin löschen",
|
||||
"detail.edit": "Bearbeiten",
|
||||
"detail.delete_confirm_title": "Termin löschen?",
|
||||
"detail.delete_msg_suffix": "wird dauerhaft gelöscht.",
|
||||
"common.delete": "Löschen",
|
||||
|
||||
// Login / server setup
|
||||
"login.username": "Benutzername",
|
||||
"login.password": "Passwort",
|
||||
"login.totp": "2FA-Code",
|
||||
"login.totp_placeholder": "6-stelliger Code",
|
||||
"login.remember": "Angemeldet bleiben",
|
||||
"login.signin": "Anmelden",
|
||||
"login.choose_server": "Anderen Server wählen",
|
||||
// Single sign-on (OpenID Connect)
|
||||
"sso.or": "oder",
|
||||
"sso.login_with": "Mit %@ anmelden",
|
||||
"sso.err.generic": "Anmeldung fehlgeschlagen",
|
||||
"sso.err.oidc_failed": "Anmeldung fehlgeschlagen",
|
||||
"sso.err.oidc_provider_unreachable": "Anmeldedienst nicht erreichbar",
|
||||
"sso.err.oidc_guided_access": "Bei aktiviertem geführten Zugriff ist die SSO-Anmeldung nicht möglich",
|
||||
"sso.err.oidc_refresh_failed": "Sitzung konnte nicht erneuert werden",
|
||||
"sso.err.oidc_no_presenter": "Anmeldefenster konnte nicht geöffnet werden",
|
||||
"sso.err.oidc_bad_issuer": "Der Anbieter ist falsch konfiguriert",
|
||||
"sso.err.oidc_account_not_linked": "Kein verknüpftes Calendarr-Konto. Bitte lokal anmelden und in den Einstellungen verknüpfen.",
|
||||
"sso.err.oidc_signup_disabled": "Automatische Kontoerstellung ist deaktiviert",
|
||||
"sso.err.oidc_domain_not_allowed": "Diese E-Mail-Domain ist nicht zugelassen",
|
||||
"sso.err.oidc_email_conflict": "Diese E-Mail-Adresse gehört bereits zu einem anderen Konto",
|
||||
"sso.err.oidc_email_not_verified": "E-Mail-Adresse beim Anbieter nicht bestätigt",
|
||||
"sso.err.oidc_no_email": "Der Anbieter hat keine E-Mail-Adresse übermittelt",
|
||||
"sso.err.oidc_unknown_client": "Diese App ist beim Anbieter nicht registriert",
|
||||
"sso.err.oidc_invalid_token": "Das Anmelde-Token ist ungültig",
|
||||
"sso.err.oidc_no_id_token": "Der Anbieter hat kein ID-Token geliefert",
|
||||
"server.connect_title": "Server verbinden",
|
||||
"server.url": "Server-URL",
|
||||
"server.connect": "Verbinden",
|
||||
"server.unreachable": "Server nicht erreichbar. URL prüfen.",
|
||||
"settings.email": "E-Mail",
|
||||
|
||||
// Accounts
|
||||
"accounts.title": "Konten",
|
||||
"accounts.loading": "Lade Konten…",
|
||||
"accounts.add.caldav": "CalDAV-Konto",
|
||||
"accounts.add.local": "Lokaler Kalender",
|
||||
"accounts.add.birthday": "Geburtstagskalender",
|
||||
"accounts.add.ical": "iCal-URL abonnieren",
|
||||
"accounts.add.ha": "Home Assistant",
|
||||
"accounts.caldav.header": "CalDAV-Konten",
|
||||
"accounts.caldav.empty": "Keine CalDAV-Konten",
|
||||
"accounts.caldav.add": "CalDAV hinzufügen",
|
||||
"accounts.local.header": "Lokale Kalender",
|
||||
"accounts.local.empty": "Keine lokalen Kalender",
|
||||
"accounts.local.add": "Lokalen Kalender erstellen",
|
||||
"accounts.ical.header": "iCal-Abonnements",
|
||||
"accounts.ical.empty": "Keine Abonnements",
|
||||
"accounts.ical.every": "Alle %d Min.",
|
||||
"accounts.ical.add": "iCal-URL abonnieren",
|
||||
"accounts.google.header": "Google-Konten",
|
||||
"accounts.google.empty": "Keine Google-Konten",
|
||||
"accounts.google.hint": "Google-Konten werden über den Browser verknüpft",
|
||||
"accounts.ha.header": "Home Assistant",
|
||||
"accounts.ha.empty": "Keine Home Assistant-Konten",
|
||||
"accounts.ha.add": "Home Assistant hinzufügen",
|
||||
"profile.admin_note": "Hinweis: Die Benutzerverwaltung – sowohl das Erstellen als auch das Löschen von Benutzerkonten – erfolgt ausschließlich durch den Administrator des Servers.",
|
||||
|
||||
// Kalender-Filter (Sidebar)
|
||||
"filter.title": "Kalender",
|
||||
"filter.loading": "Lade Kalender…",
|
||||
"filter.empty": "Keine Kalender vorhanden",
|
||||
"filter.show_all": "Alle anzeigen",
|
||||
"filter.hide_all": "Alle ausblenden",
|
||||
"filter.sort": "Sortieren",
|
||||
"filter.done": "Fertig",
|
||||
"filter.button": "Kalender ein-/ausblenden",
|
||||
"filter.banish": "Dauerhaft ausblenden",
|
||||
"filter.reminders_on": "Benachrichtigungen an",
|
||||
"filter.reminders_off": "Benachrichtigungen aus",
|
||||
"filter.banished_footer": "Dauerhaft ausgeblendete Kalender erscheinen unter »Konten & Kalender« und können dort wieder eingeblendet werden.",
|
||||
"accounts.banished_header": "Ausgeblendete Kalender",
|
||||
"accounts.banished_unhide": "Wieder einblenden",
|
||||
"accounts.banished_unknown": "Unbekannter Kalender",
|
||||
|
||||
// CalDAV add sheet
|
||||
"caldav.section": "Konto-Details",
|
||||
"caldav.display_name": "Anzeigename",
|
||||
"caldav.url": "CalDAV-URL",
|
||||
"caldav.username": "Benutzername",
|
||||
"caldav.password": "Passwort",
|
||||
"caldav.color": "Farbe",
|
||||
"caldav.color_label": "Farbe",
|
||||
"caldav.color_section": "Farbe",
|
||||
"caldav.connect": "Verbinden",
|
||||
"caldav.title": "CalDAV-Konto",
|
||||
|
||||
// Local cal add sheet
|
||||
"local.title": "Lokaler Kalender",
|
||||
"local.name": "Name",
|
||||
"local.color": "Farbe",
|
||||
"local.create": "Erstellen",
|
||||
|
||||
// Birthdays
|
||||
"birthday.new": "Neuer Geburtstag",
|
||||
"birthday.calendar_name": "Geburtstage",
|
||||
"birthday.activate": "Geburtstagskalender aktivieren",
|
||||
"birthday.activate_hint": "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.",
|
||||
"birthday.person": "Name",
|
||||
"birthday.person_placeholder": "Name der Person",
|
||||
"birthday.date": "Geburtstag",
|
||||
"birthday.year_unknown": "Jahr unbekannt",
|
||||
"birthday.no_calendars": "Kein Geburtstagskalender vorhanden. Erstelle zuerst einen unter „Konten & Kalender“.",
|
||||
"birthday.is_calendar": "Geburtstagskalender",
|
||||
"birthday.is_calendar.desc": "Ganztägige, jährliche Termine mit Alter und Geburtstags-Icon.",
|
||||
"birthday.notify": "Erinnerung",
|
||||
"birthday.notify.off": "Aus",
|
||||
"birthday.notify.same_day": "Am Tag",
|
||||
"birthday.notify.one_day": "1 Tag vorher",
|
||||
"birthday.notify.days": "%d Tage vorher",
|
||||
"birthday.contacts.header": "Geburtstage aus Kontakten",
|
||||
"birthday.contacts.sync": "Aus Kontakten synchronisieren",
|
||||
"birthday.contacts.target": "Zielkalender",
|
||||
"birthday.contacts.sync_now": "Jetzt synchronisieren",
|
||||
"birthday.contacts.synced": "Geburtstage synchronisiert",
|
||||
"birthday.contacts.hint": "Überträgt Geburtstage aus deinen Kontakten in den gewählten Geburtstagskalender – sichtbar auf allen Geräten.",
|
||||
"birthday.contacts.denied": "Kein Zugriff auf Kontakte. Bitte in den iOS-Einstellungen erlauben.",
|
||||
"birthday.contacts.need_calendar": "Erstelle zuerst einen Geburtstagskalender.",
|
||||
|
||||
// iCal add sheet
|
||||
"ical.title": "iCal abonnieren",
|
||||
"ical.subscription": "Abonnement",
|
||||
"ical.name": "Name",
|
||||
"ical.url": "iCal-URL",
|
||||
"ical.color": "Farbe",
|
||||
"ical.refresh_section": "Aktualisierung",
|
||||
"ical.interval": "Intervall",
|
||||
"ical.subscribe": "Abonnieren",
|
||||
"ical.refresh.15m": "Alle 15 Min.",
|
||||
"ical.refresh.30m": "Alle 30 Min.",
|
||||
"ical.refresh.1h": "Stündlich",
|
||||
"ical.refresh.6h": "Alle 6 Std.",
|
||||
"ical.refresh.1d": "Täglich",
|
||||
|
||||
// HA add sheet
|
||||
"ha.section": "Home Assistant",
|
||||
"ha.display_name": "Anzeigename",
|
||||
"ha.url_placeholder": "URL (z.B. http://homeassistant.local:8123)",
|
||||
"ha.auth_section": "Authentifizierung",
|
||||
"ha.token": "Long-Lived Access Token",
|
||||
"ha.token_hint": "Token erstellen unter: Profil → Sicherheit → Long-Lived Access Tokens",
|
||||
"ha.connect": "Verbinden"
|
||||
],
|
||||
"en": [
|
||||
"nav.today": "Today",
|
||||
"nav.menu": "Menu",
|
||||
"nav.done": "Done",
|
||||
|
||||
"view.month": "Month",
|
||||
"view.week": "Week",
|
||||
"view.day": "Day",
|
||||
"view.quarter": "Quarter",
|
||||
"view.agenda": "Agenda",
|
||||
"view.change": "View",
|
||||
|
||||
"cal.cw": "W",
|
||||
"cal.allday": "All-day",
|
||||
"cal.no_events_title": "No events",
|
||||
"cal.no_events_body": "No events in the next 90 days.",
|
||||
"cal.loading_more": "Loading more weeks…",
|
||||
"cal.new_event": "New event",
|
||||
"cal.show_in_day_view": "Open in day view",
|
||||
"cal.show_in_week_view": "Open in week view",
|
||||
"cal.show_in_month_view": "Open in month view",
|
||||
|
||||
"menu.section.settings": "Settings",
|
||||
"menu.profile": "Profile",
|
||||
"menu.appearance": "Appearance",
|
||||
"menu.accounts": "Accounts & Calendars",
|
||||
"menu.server": "Server",
|
||||
"menu.logout": "Sign out",
|
||||
"menu.admin": "Admin",
|
||||
"menu.sync": "Sync with server",
|
||||
"menu.sync.section": "Synchronization",
|
||||
|
||||
"settings.title": "Appearance",
|
||||
"settings.loading": "Loading settings…",
|
||||
"settings.save": "Save",
|
||||
"settings.saved": "Saved",
|
||||
|
||||
"settings.appdesign": "App design",
|
||||
"settings.liquidglass": "Liquid Glass",
|
||||
"settings.liquidglass.desc": "Uses the new iOS\u{202F}26 glass look with a translucent navigation bar",
|
||||
"settings.liquidglass.footer": "Takes effect immediately – no restart required.",
|
||||
"settings.sync": "Sync settings",
|
||||
"settings.sync.desc": "Keep appearance in sync with the server",
|
||||
"settings.sync.footer": "When on, colors, contrasts and hour height sync with the server (the server wins). View, first weekday and dimming past events always sync – even when the switch is off.",
|
||||
"settings.sync_all": "Sync all",
|
||||
"settings.sync_all.desc": "Share these settings across your devices",
|
||||
"settings.sync_this": "Sync across devices",
|
||||
"settings.reset": "Reset",
|
||||
"settings.appearance": "View",
|
||||
"settings.color.surface": "Top bar / surface color",
|
||||
"settings.surface.auto": "Auto (translucent)",
|
||||
"settings.device": "This device only",
|
||||
"settings.hide_menu_button": "Hide menu button",
|
||||
"settings.device.footer": "These settings apply to this device only and are not synced.",
|
||||
|
||||
"settings.cache.header": "Preloading",
|
||||
"settings.cache.title": "Preloading",
|
||||
"settings.cache.desc": "Events are loaded in the background for this range on launch, so swiping is instant afterwards.",
|
||||
"settings.cache.range": "Range",
|
||||
"settings.cache.1m": "±1 month",
|
||||
"settings.cache.3m": "±3 months",
|
||||
"settings.cache.6m": "±6 months",
|
||||
"settings.cache.1y": "±1 year",
|
||||
"settings.cache.footer": "More months = longer initial load, but then fully wait-free navigation.",
|
||||
|
||||
"settings.language": "Language",
|
||||
"lang.system": "System default",
|
||||
"lang.german": "Deutsch",
|
||||
"lang.english": "English",
|
||||
|
||||
"settings.colors": "Colors",
|
||||
"settings.color.primary": "Primary color",
|
||||
"settings.color.accent": "Accent color",
|
||||
"settings.color.today": "Today color",
|
||||
"settings.color.divider": "Month divider line",
|
||||
"settings.color.label": "Month abbreviation",
|
||||
"settings.color.text": "Text color",
|
||||
"settings.color.background": "Background color",
|
||||
"settings.color.line": "Line color",
|
||||
|
||||
"settings.textcontrast": "Text contrast",
|
||||
"settings.textcontrast.desc": "Brightness of labels and text",
|
||||
"settings.contrast.dark": "Dark",
|
||||
"settings.contrast.medium": "Medium",
|
||||
"settings.contrast.bright": "Bright",
|
||||
"settings.contrast.max": "Maximum",
|
||||
|
||||
"settings.linecontrast": "Line contrast",
|
||||
"settings.linecontrast.desc": "Visibility of dividers and borders",
|
||||
"settings.linecontrast.barely": "Barely",
|
||||
"settings.linecontrast.subtle": "Subtle",
|
||||
"settings.linecontrast.normal": "Normal",
|
||||
"settings.linecontrast.strong": "Strong",
|
||||
|
||||
"settings.calview": "Calendar view",
|
||||
"settings.defaultview": "Default view",
|
||||
"settings.firstweekday": "First day of week",
|
||||
"settings.monday": "Monday",
|
||||
"settings.sunday": "Sunday",
|
||||
"settings.dimpast": "Dim past events",
|
||||
"settings.month_paged": "Swipe month view as pages",
|
||||
"settings.month_mode": "Month switching",
|
||||
"settings.month_mode.scroll": "Continuous scroll",
|
||||
"settings.month_mode.paged": "Page by page",
|
||||
"settings.default_duration": "Default event duration",
|
||||
"settings.nav.profile": "Profile",
|
||||
"settings.privacy": "Privacy",
|
||||
"settings.private_visibility": "Private events for groups",
|
||||
"settings.private_visibility.desc": "How your private events appear to other group members",
|
||||
"settings.directory_hidden": "Hide my profile",
|
||||
"settings.directory_hidden.desc": "Don't show me in share/group pickers. You stay visible in the admin user management.",
|
||||
"settings.private.busy": "Show as \"Busy\"",
|
||||
"settings.private.hidden": "Hide",
|
||||
"settings.calendars": "Shared calendar",
|
||||
"settings.group_visible": "Visible to groups",
|
||||
"settings.group_visible.desc": "Choose which of your calendars group members can see",
|
||||
"group.visible.none": "None",
|
||||
"profile.display_name": "Display name",
|
||||
"profile.login_name": "Login name",
|
||||
"accounts.shared_by": "shared by %@",
|
||||
"share.title": "Share",
|
||||
"share.current": "Current shares",
|
||||
"share.none": "Not shared yet",
|
||||
"share.add": "Add user",
|
||||
"share.search": "Search users…",
|
||||
"share.permission": "Permission",
|
||||
"perm.read": "Read only",
|
||||
"perm.read_write": "Read & write",
|
||||
"ics.import": "Import",
|
||||
"ics.export": "Export",
|
||||
"ics.import_result": "%d imported, %d skipped",
|
||||
"common.info": "Info",
|
||||
"common.done": "Done",
|
||||
"groups.title": "Groups",
|
||||
"groups.personal": "Personal",
|
||||
"groups.view_label": "Group view",
|
||||
"groups.exit": "Exit",
|
||||
"groups.none": "No groups yet",
|
||||
"groups.combined_empty": "No events in this period",
|
||||
"group.create": "Create group",
|
||||
"group.manage": "Manage group",
|
||||
"group.name": "Name",
|
||||
"group.icon": "Icon",
|
||||
"group.members": "Members",
|
||||
"group.calendar": "Group calendar",
|
||||
"group.member_colors": "Member colours",
|
||||
"group.delete": "Delete group",
|
||||
|
||||
"settings.hourheight": "Hour height",
|
||||
"settings.hourheight.desc": "Space per hour in week & day view",
|
||||
"settings.hourheight.compact": "Compact",
|
||||
"settings.hourheight.normal": "Normal",
|
||||
"settings.hourheight.comfort": "Comfort",
|
||||
"settings.hourheight.large": "Large",
|
||||
|
||||
// Common buttons
|
||||
"common.cancel": "Cancel",
|
||||
"common.close": "Close",
|
||||
"common.ok": "OK",
|
||||
"common.error": "Error",
|
||||
|
||||
// Server view
|
||||
"server.title": "Server",
|
||||
"server.connected": "Connected server",
|
||||
"server.switch": "Switch server",
|
||||
"server.switch_msg": "The connection to %@ will be closed and all local credentials will be removed.",
|
||||
"server.logout_title": "Sign out",
|
||||
"server.logout_msg": "You will be signed out of %@.",
|
||||
"server.info": "Info",
|
||||
"server.imprint": "Legal notice",
|
||||
"server.version": "Version",
|
||||
|
||||
// Imprint
|
||||
"imprint.company": "Scarriffleservices",
|
||||
"imprint.role": "Software & web development",
|
||||
"imprint.copyright": "This software was carefully developed and provided by Scarriffleservices. All rights reserved © 2026 Scarriffleservices.",
|
||||
"imprint.storage.title": "Data storage",
|
||||
"imprint.storage.body": "All application data is stored and processed on the server on which this Calendarr instance is hosted. The storage location therefore depends on the operator of the respective server. When using the Google Calendar integration, data is exchanged via the Google API; Google's privacy policy applies to that data. When using the Home Assistant integration, data is exchanged with the respective Home Assistant instance. Home Assistant is a project of the Open Home Foundation.",
|
||||
"imprint.disclaimer.title": "Disclaimer",
|
||||
"imprint.disclaimer.body": "Despite careful preparation, no liability is assumed for the accuracy, completeness or topicality of the content provided. Use is at your own risk.",
|
||||
"imprint.contact.title": "Contact",
|
||||
|
||||
// Profile view
|
||||
"profile.title": "Profile",
|
||||
"profile.loading": "Loading profile…",
|
||||
"profile.account": "Account",
|
||||
"profile.username": "Username",
|
||||
"profile.role": "Role",
|
||||
"profile.role.admin": "Administrator",
|
||||
"profile.role.user": "User",
|
||||
"profile.email": "Email",
|
||||
"profile.no_email": "No email",
|
||||
"profile.save_email": "Save email",
|
||||
"profile.email_saved": "Email saved",
|
||||
"profile.change_password": "Change password",
|
||||
"profile.current_password": "Current password",
|
||||
"profile.new_password": "New password",
|
||||
"profile.new_password_repeat": "Repeat new password",
|
||||
"profile.password_mismatch": "Passwords don't match",
|
||||
"profile.password_changed": "Password changed",
|
||||
"profile.twofa": "Two-factor authentication",
|
||||
"profile.twofa.active": "2FA is enabled",
|
||||
"profile.twofa.inactive": "2FA is disabled",
|
||||
"profile.twofa.enable": "Set up 2FA",
|
||||
"profile.twofa.disable": "Disable 2FA",
|
||||
"profile.twofa.enabled_toast": "2FA enabled",
|
||||
"profile.twofa.disabled_toast": "2FA disabled",
|
||||
"twofa.setup_title": "Set up 2FA",
|
||||
"twofa.scan_hint": "Scan the QR code with your authenticator app (e.g. Bitwarden, Google Authenticator).",
|
||||
"twofa.qr_section": "QR code / Manual key",
|
||||
"twofa.confirmation": "Verification",
|
||||
"twofa.code_placeholder": "6-digit code",
|
||||
"twofa.activate": "Activate",
|
||||
"twofa.disable_title": "Disable 2FA",
|
||||
"twofa.password_section": "Password to disable",
|
||||
"twofa.password_placeholder": "Password",
|
||||
"twofa.disable": "Disable",
|
||||
|
||||
// Event editor
|
||||
"event.title_placeholder": "Title",
|
||||
"event.allday": "All-day",
|
||||
"event.private": "Private",
|
||||
"event.start": "Start",
|
||||
"event.end": "End",
|
||||
"event.location": "Location",
|
||||
"event.description": "Description",
|
||||
"event.calendar_section": "Calendar",
|
||||
"event.no_writable": "No writable calendars available",
|
||||
"event.calendar_picker": "Calendar",
|
||||
"event.color_section": "Color",
|
||||
"event.color": "Event color",
|
||||
"event.reset_color": "Reset",
|
||||
"event.edit_title": "Edit event",
|
||||
"event.new_title": "New event",
|
||||
"event.copy_title": "Copy event",
|
||||
"event.copy_to": "Copy to calendar",
|
||||
"event.save": "Save",
|
||||
"event.add": "Add",
|
||||
|
||||
// Event detail
|
||||
"detail.title": "Event",
|
||||
"detail.source": "Source",
|
||||
"detail.created_by": "Created by",
|
||||
"detail.delete": "Delete event",
|
||||
"detail.edit": "Edit",
|
||||
"detail.delete_confirm_title": "Delete event?",
|
||||
"detail.delete_msg_suffix": "will be permanently deleted.",
|
||||
"common.delete": "Delete",
|
||||
|
||||
// Login / server setup
|
||||
"login.username": "Username",
|
||||
"login.password": "Password",
|
||||
"login.totp": "2FA code",
|
||||
"login.totp_placeholder": "6-digit code",
|
||||
"login.remember": "Stay signed in",
|
||||
"login.signin": "Sign in",
|
||||
"login.choose_server": "Choose another server",
|
||||
// Single sign-on (OpenID Connect)
|
||||
"sso.or": "or",
|
||||
"sso.login_with": "Sign in with %@",
|
||||
"sso.err.generic": "Sign-in failed",
|
||||
"sso.err.oidc_failed": "Sign-in failed",
|
||||
"sso.err.oidc_provider_unreachable": "Sign-in service unreachable",
|
||||
"sso.err.oidc_guided_access": "SSO sign-in is not available while Guided Access is on",
|
||||
"sso.err.oidc_refresh_failed": "Could not renew the session",
|
||||
"sso.err.oidc_no_presenter": "Could not open the sign-in window",
|
||||
"sso.err.oidc_bad_issuer": "The provider is misconfigured",
|
||||
"sso.err.oidc_account_not_linked": "No linked Calendarr account. Sign in locally and link it in Settings.",
|
||||
"sso.err.oidc_signup_disabled": "Automatic account creation is disabled",
|
||||
"sso.err.oidc_domain_not_allowed": "This email domain is not allowed",
|
||||
"sso.err.oidc_email_conflict": "That email address already belongs to another account",
|
||||
"sso.err.oidc_email_not_verified": "Email address not verified at the provider",
|
||||
"sso.err.oidc_no_email": "The provider did not supply an email address",
|
||||
"sso.err.oidc_unknown_client": "This app is not registered with the provider",
|
||||
"sso.err.oidc_invalid_token": "The sign-in token is invalid",
|
||||
"sso.err.oidc_no_id_token": "The provider did not return an ID token",
|
||||
"server.connect_title": "Connect server",
|
||||
"server.url": "Server URL",
|
||||
"server.connect": "Connect",
|
||||
"server.unreachable": "Server unreachable. Check the URL.",
|
||||
"settings.email": "Email",
|
||||
|
||||
// Accounts
|
||||
"accounts.title": "Accounts",
|
||||
"accounts.loading": "Loading accounts…",
|
||||
"accounts.add.caldav": "CalDAV account",
|
||||
"accounts.add.local": "Local calendar",
|
||||
"accounts.add.birthday": "Birthday calendar",
|
||||
"accounts.add.ical": "Subscribe to iCal URL",
|
||||
"accounts.add.ha": "Home Assistant",
|
||||
"accounts.caldav.header": "CalDAV accounts",
|
||||
"accounts.caldav.empty": "No CalDAV accounts",
|
||||
"accounts.caldav.add": "Add CalDAV",
|
||||
"accounts.local.header": "Local calendars",
|
||||
"accounts.local.empty": "No local calendars",
|
||||
"accounts.local.add": "Create local calendar",
|
||||
"accounts.ical.header": "iCal subscriptions",
|
||||
"accounts.ical.empty": "No subscriptions",
|
||||
"accounts.ical.every": "Every %d min",
|
||||
"accounts.ical.add": "Subscribe to iCal URL",
|
||||
"accounts.google.header": "Google accounts",
|
||||
"accounts.google.empty": "No Google accounts",
|
||||
"accounts.google.hint": "Google accounts are linked via the browser",
|
||||
"accounts.ha.header": "Home Assistant",
|
||||
"accounts.ha.empty": "No Home Assistant accounts",
|
||||
"accounts.ha.add": "Add Home Assistant",
|
||||
"profile.admin_note": "Note: User management — both the creation and deletion of user accounts — is handled exclusively by the server administrator.",
|
||||
|
||||
// Calendar filter (sidebar)
|
||||
"filter.title": "Calendars",
|
||||
"filter.loading": "Loading calendars…",
|
||||
"filter.empty": "No calendars available",
|
||||
"filter.show_all": "Show all",
|
||||
"filter.hide_all": "Hide all",
|
||||
"filter.sort": "Sort",
|
||||
"filter.done": "Done",
|
||||
"filter.button": "Show/hide calendars",
|
||||
"filter.banish": "Hide permanently",
|
||||
"filter.reminders_on": "Reminders on",
|
||||
"filter.reminders_off": "Reminders off",
|
||||
"filter.banished_footer": "Permanently hidden calendars appear under “Accounts & Calendars”, where you can show them again.",
|
||||
"accounts.banished_header": "Hidden calendars",
|
||||
"accounts.banished_unhide": "Show again",
|
||||
"accounts.banished_unknown": "Unknown calendar",
|
||||
|
||||
// CalDAV add sheet
|
||||
"caldav.section": "Account details",
|
||||
"caldav.display_name": "Display name",
|
||||
"caldav.url": "CalDAV URL",
|
||||
"caldav.username": "Username",
|
||||
"caldav.password": "Password",
|
||||
"caldav.color": "Color",
|
||||
"caldav.color_label": "Color",
|
||||
"caldav.color_section": "Color",
|
||||
"caldav.connect": "Connect",
|
||||
"caldav.title": "CalDAV account",
|
||||
|
||||
// Local cal add sheet
|
||||
"local.title": "Local calendar",
|
||||
"local.name": "Name",
|
||||
"local.color": "Color",
|
||||
"local.create": "Create",
|
||||
|
||||
// Birthdays
|
||||
"birthday.new": "New birthday",
|
||||
"birthday.calendar_name": "Birthdays",
|
||||
"birthday.activate": "Enable birthday calendar",
|
||||
"birthday.activate_hint": "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.",
|
||||
"birthday.person": "Name",
|
||||
"birthday.person_placeholder": "Person's name",
|
||||
"birthday.date": "Birthday",
|
||||
"birthday.year_unknown": "Year unknown",
|
||||
"birthday.no_calendars": "No birthday calendar yet. Create one first under “Accounts & Calendars”.",
|
||||
"birthday.is_calendar": "Birthday calendar",
|
||||
"birthday.is_calendar.desc": "All-day, yearly events with age and a birthday icon.",
|
||||
"birthday.notify": "Reminder",
|
||||
"birthday.notify.off": "Off",
|
||||
"birthday.notify.same_day": "On the day",
|
||||
"birthday.notify.one_day": "1 day before",
|
||||
"birthday.notify.days": "%d days before",
|
||||
"birthday.contacts.header": "Birthdays from Contacts",
|
||||
"birthday.contacts.sync": "Sync from Contacts",
|
||||
"birthday.contacts.target": "Target calendar",
|
||||
"birthday.contacts.sync_now": "Sync now",
|
||||
"birthday.contacts.synced": "Birthdays synced",
|
||||
"birthday.contacts.hint": "Copies birthdays from your contacts into the chosen birthday calendar – visible on all devices.",
|
||||
"birthday.contacts.denied": "No access to Contacts. Please allow it in iOS Settings.",
|
||||
"birthday.contacts.need_calendar": "Create a birthday calendar first.",
|
||||
|
||||
// iCal add sheet
|
||||
"ical.title": "Subscribe to iCal",
|
||||
"ical.subscription": "Subscription",
|
||||
"ical.name": "Name",
|
||||
"ical.url": "iCal URL",
|
||||
"ical.color": "Color",
|
||||
"ical.refresh_section": "Refresh",
|
||||
"ical.interval": "Interval",
|
||||
"ical.subscribe": "Subscribe",
|
||||
"ical.refresh.15m": "Every 15 min",
|
||||
"ical.refresh.30m": "Every 30 min",
|
||||
"ical.refresh.1h": "Hourly",
|
||||
"ical.refresh.6h": "Every 6 hours",
|
||||
"ical.refresh.1d": "Daily",
|
||||
|
||||
// HA add sheet
|
||||
"ha.section": "Home Assistant",
|
||||
"ha.display_name": "Display name",
|
||||
"ha.url_placeholder": "URL (e.g. http://homeassistant.local:8123)",
|
||||
"ha.auth_section": "Authentication",
|
||||
"ha.token": "Long-Lived Access Token",
|
||||
"ha.token_hint": "Create a token under: Profile → Security → Long-Lived Access Tokens",
|
||||
"ha.connect": "Connect"
|
||||
]
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
|
||||
/// Reminder offset options (minutes before an event's start; 0 = at start) and
|
||||
/// their localized labels. Shared by the event editor, the settings default
|
||||
/// picker, and the notification scheduler so the choices stay consistent.
|
||||
enum ReminderOptions {
|
||||
/// Selectable offsets in minutes-before-start.
|
||||
static let all: [Int] = [0, 5, 15, 30, 60, 1440, 10080]
|
||||
|
||||
/// Quick presets shown in the picker; everything else is entered as a
|
||||
/// custom number + unit. Default for a freshly-switched custom row.
|
||||
static let presets: [Int] = [0, 30, 1440] // at start, 30 min, 1 day
|
||||
static let customDefault = 120 // 2 hours (deliberately not a preset)
|
||||
|
||||
/// Time units for the custom picker (value × mult = minutes-before-start).
|
||||
enum Unit: Int, CaseIterable, Identifiable {
|
||||
case minutes, hours, days, weeks
|
||||
var id: Int { rawValue }
|
||||
var mult: Int {
|
||||
switch self {
|
||||
case .minutes: return 1
|
||||
case .hours: return 60
|
||||
case .days: return 1440
|
||||
case .weeks: return 10080
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a minutes value into the largest exact {value, unit} for the custom picker.
|
||||
static func split(_ minutes: Int) -> (value: Int, unit: Unit) {
|
||||
for u in Unit.allCases.reversed() where minutes > 0 && minutes % u.mult == 0 {
|
||||
return (minutes / u.mult, u)
|
||||
}
|
||||
return (max(1, minutes), .minutes)
|
||||
}
|
||||
|
||||
static func unitLabel(_ u: Unit, _ l: String) -> String {
|
||||
let en = isEnglish(l)
|
||||
switch u {
|
||||
case .minutes: return en ? "minutes" : "Minuten"
|
||||
case .hours: return en ? "hours" : "Stunden"
|
||||
case .days: return en ? "days" : "Tage"
|
||||
case .weeks: return en ? "weeks" : "Wochen"
|
||||
}
|
||||
}
|
||||
/// Human label for a duration in minutes (no "before" suffix), e.g. "1 h", "30 min".
|
||||
static func durationLabel(_ minutes: Int, _ l: String) -> String {
|
||||
let en = isEnglish(l)
|
||||
if minutes % 60 == 0 {
|
||||
let h = minutes / 60
|
||||
return en ? "\(h) h" : "\(h) Std."
|
||||
}
|
||||
if minutes > 60 {
|
||||
let h = minutes / 60, m = minutes % 60
|
||||
return "\(h):\(String(format: "%02d", m)) h"
|
||||
}
|
||||
return en ? "\(minutes) min" : "\(minutes) Min."
|
||||
}
|
||||
static func customLabel(_ l: String) -> String { isEnglish(l) ? "Custom…" : "Benutzerdefiniert…" }
|
||||
static func beforeLabel(_ l: String) -> String { isEnglish(l) ? "before" : "vorher" }
|
||||
static func disabledNote(_ l: String) -> String {
|
||||
isEnglish(l)
|
||||
? "Reminders are disabled for this calendar – they will not fire."
|
||||
: "Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt."
|
||||
}
|
||||
|
||||
private static func isEnglish(_ appLang: String) -> Bool {
|
||||
if appLang == "en" { return true }
|
||||
if appLang == "de" { return false }
|
||||
return (Locale.current.language.languageCode?.identifier ?? "de").hasPrefix("en")
|
||||
}
|
||||
|
||||
static func label(_ minutes: Int, _ appLang: String) -> String {
|
||||
let en = isEnglish(appLang)
|
||||
if minutes <= 0 { return en ? "At start time" : "Zur Startzeit" }
|
||||
if minutes < 60 {
|
||||
return en ? "\(minutes) minutes before" : "\(minutes) Minuten vorher"
|
||||
}
|
||||
if minutes < 1440 {
|
||||
let h = minutes / 60
|
||||
if en { return h == 1 ? "1 hour before" : "\(h) hours before" }
|
||||
return h == 1 ? "1 Stunde vorher" : "\(h) Stunden vorher"
|
||||
}
|
||||
if minutes < 10080 {
|
||||
let d = minutes / 1440
|
||||
if en { return d == 1 ? "1 day before" : "\(d) days before" }
|
||||
return d == 1 ? "1 Tag vorher" : "\(d) Tage vorher"
|
||||
}
|
||||
let w = minutes / 10080
|
||||
if en { return w == 1 ? "1 week before" : "\(w) weeks before" }
|
||||
return w == 1 ? "1 Woche vorher" : "\(w) Wochen vorher"
|
||||
}
|
||||
|
||||
static func sectionTitle(_ l: String) -> String { isEnglish(l) ? "Reminders" : "Benachrichtigungen" }
|
||||
static func addLabel(_ l: String) -> String { isEnglish(l) ? "Add reminder" : "Benachrichtigung hinzufügen" }
|
||||
static func off(_ l: String) -> String { isEnglish(l) ? "Off" : "Aus" }
|
||||
static func defaultTitle(_ l: String) -> String { isEnglish(l) ? "Default reminder" : "Standardbenachrichtigung" }
|
||||
static func defaultFooter(_ l: String) -> String {
|
||||
isEnglish(l)
|
||||
? "Applies to all events unless an event has its own reminders."
|
||||
: "Gilt für alle Termine, sofern ein Termin keine eigenen Benachrichtigungen hat."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user