From 9f864617c2fa0009081aba95c3e7a785ef9279b6 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 19 Oct 2026 05:54:05 +0200 Subject: [PATCH] Let a hidden iCal calendar become visible again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An iCal subscription could be banished but never un-banished. The server has always sent and accepted sidebar_hidden for subscriptions, but iOS left the field off ICalSubscription entirely and omitted "ical" from serverManagedSources, from setCalendarSidebarHidden and from both places that reconcile the server's flag. So the key went into banishedCalendarKeys and nothing ever took it out: refreshFromCache filters on that set as well as on hiddenCalendarKeys, which is why showing the calendar again left its events invisible and no amount of syncing helped. Closed on all four sides. The account models are also no longer brittle about absent fields. HomeAssistantAccount required auth_method, and CalDAVCalendar and GoogleCalendar required sidebar_hidden, with no custom decoder — one missing key threw, the `try?` at the call site turned that into an empty list, and the reconciliation silently did not run, stranding calendars as banished in exactly the same way. HACalendar already carried a lenient decoder for this reason; the other three now do too. Co-Authored-By: Claude Opus 5 --- ios/Calendarr iOS/Models/AppSettings.swift | 44 +++++++++++++++++++ ios/Calendarr iOS/Models/CalendarStore.swift | 6 ++- ios/Calendarr iOS/Services/CalendarrAPI.swift | 1 + .../Views/CalendarFilterContent.swift | 1 + 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/ios/Calendarr iOS/Models/AppSettings.swift b/ios/Calendarr iOS/Models/AppSettings.swift index caa7587..cea3217 100644 --- a/ios/Calendarr iOS/Models/AppSettings.swift +++ b/ios/Calendarr iOS/Models/AppSettings.swift @@ -111,6 +111,16 @@ struct CalDAVCalendar: Codable, Identifiable { 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.decodeIfPresent(String.self, forKey: .name) ?? "" + 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) + } } struct LocalCalendar: Codable, Identifiable { @@ -161,12 +171,27 @@ struct ICalSubscription: Codable, Identifiable { var refreshMinutes: Int var lastFetched: String? var remindersEnabled: Bool? + var sidebarHidden: Bool = false enum CodingKeys: String, CodingKey { case id, name, url, color, enabled case refreshMinutes = "refresh_minutes" case lastFetched = "last_fetched" case remindersEnabled = "reminders_enabled" + case sidebarHidden = "sidebar_hidden" + } + + 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) ?? "" + url = try c.decodeIfPresent(String.self, forKey: .url) ?? "" + color = try c.decodeIfPresent(String.self, forKey: .color) ?? "#4285f4" + enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + refreshMinutes = try c.decodeIfPresent(Int.self, forKey: .refreshMinutes) ?? 60 + lastFetched = try c.decodeIfPresent(String.self, forKey: .lastFetched) + remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) + sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false } } @@ -189,6 +214,16 @@ struct GoogleCalendar: Codable, Identifiable { 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.decodeIfPresent(String.self, forKey: .name) ?? "" + 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) + } } struct HomeAssistantAccount: Codable, Identifiable { @@ -202,6 +237,15 @@ struct HomeAssistantAccount: Codable, Identifiable { case id, name, url, calendars case authMethod = "auth_method" } + + 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) ?? "Home Assistant" + url = try c.decodeIfPresent(String.self, forKey: .url) ?? "" + authMethod = try c.decodeIfPresent(String.self, forKey: .authMethod) ?? "token" + calendars = try c.decodeIfPresent([HACalendar].self, forKey: .calendars) + } } struct HACalendar: Codable, Identifiable { diff --git a/ios/Calendarr iOS/Models/CalendarStore.swift b/ios/Calendarr iOS/Models/CalendarStore.swift index a2fb2ab..c66929c 100644 --- a/ios/Calendarr iOS/Models/CalendarStore.swift +++ b/ios/Calendarr iOS/Models/CalendarStore.swift @@ -287,7 +287,8 @@ class CalendarStore { 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) + async let i = (try? await api.getICalSubscriptions()) ?? [] + let (caldav, google, ha, ical) = await (c, g, h, i) var b = banishedCalendarKeys func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) { @@ -297,6 +298,7 @@ class CalendarStore { 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) } } + for sub in ical { applyServerHidden("ical", sub.id, sub.sidebarHidden) } guard b != banishedCalendarKeys else { return false } banishedCalendarKeys = b @@ -350,7 +352,7 @@ class CalendarStore { } /// Sources whose visibility is backed by the server's `sidebar_hidden`. - static let serverManagedSources: Set = ["caldav", "google", "homeassistant"] + static let serverManagedSources: Set = ["caldav", "google", "homeassistant", "ical"] var userCalendar: Calendar { var cal = Calendar.current diff --git a/ios/Calendarr iOS/Services/CalendarrAPI.swift b/ios/Calendarr iOS/Services/CalendarrAPI.swift index 0f4bd46..a1f99c8 100644 --- a/ios/Calendarr iOS/Services/CalendarrAPI.swift +++ b/ios/Calendarr iOS/Services/CalendarrAPI.swift @@ -517,6 +517,7 @@ class CalendarrAPI { case "caldav": path = "/api/caldav/calendars/\(calendarId)" case "google": path = "/api/google/calendars/\(calendarId)" case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)" + case "ical": path = "/api/ical/subscriptions/\(calendarId)" default: return } _ = try await request(path, method: "PUT", diff --git a/ios/Calendarr iOS/Views/CalendarFilterContent.swift b/ios/Calendarr iOS/Views/CalendarFilterContent.swift index 8b55dce..5c524d8 100644 --- a/ios/Calendarr iOS/Views/CalendarFilterContent.swift +++ b/ios/Calendarr iOS/Views/CalendarFilterContent.swift @@ -213,6 +213,7 @@ struct CalendarFilterContent: View { for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } } for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } } for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } } + for sub in icalSubs { applyServerHidden("ical", sub.id, sub.sidebarHidden) } store.setBanishedCalendars(b) banished = b