From 69ed8bf67ddcf358ff029648c43289bf869f5fa4 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 13 Jul 2026 19:07:46 +0200 Subject: [PATCH] Birthdays: single calendar model + sync on server-sync (iOS) - one birthday calendar per user; BirthdaysImporter auto-creates/targets it (no picker) and reconciles per-device (external_uid contact::) so multiple devices don't clash - Contacts birthday sync now runs on every "Sync with server" (syncFromServer), not just app launch and the manual button - report the device after each sync (POST /api/birthdays/sync-report) for the web device list - Accounts: Birthdays-from-Contacts section drops the target picker, keeps the reminder picker; removed the birthday toggle from the generic new-calendar sheet - New-birthday sheet targets the single calendar, offers activation if none - exclude the birthday calendar from the group-visible ("shared calendar") picker Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/Localization.swift | 6 ++ .../Services/BirthdaysImporter.swift | 101 ++++++++++++------ Calendarr iOS/Services/CalendarrAPI.swift | 12 ++- Calendarr iOS/Views/AccountsView.swift | 65 +++++------ .../Views/Calendar/BirthdayEditorSheet.swift | 57 +++++----- .../Views/Calendar/CalendarHostView.swift | 3 + Calendarr iOS/Views/SettingsView.swift | 4 +- 7 files changed, 144 insertions(+), 104 deletions(-) diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 811fdff..6bd6176 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -347,6 +347,9 @@ private let strings: [String: [String: String]] = [ // 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", @@ -702,6 +705,9 @@ private let strings: [String: [String: String]] = [ // 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", diff --git a/Calendarr iOS/Services/BirthdaysImporter.swift b/Calendarr iOS/Services/BirthdaysImporter.swift index 8458c5c..471ca81 100644 --- a/Calendarr iOS/Services/BirthdaysImporter.swift +++ b/Calendarr iOS/Services/BirthdaysImporter.swift @@ -1,5 +1,6 @@ import Foundation import Contacts +import UIKit /// One stored birthday row as returned by GET /api/local/calendars/{id}/birthdays. /// Contact-sourced rows carry an `externalUid`; manually added ones have `nil`. @@ -19,31 +20,41 @@ struct BirthdayEntry: Codable, Identifiable { } } -/// Reads birthdays from the system Contacts and mirrors them into a chosen -/// birthday `LocalCalendar` on the backend, so they show on every client. +/// Reads birthdays from the system Contacts and mirrors them into the user's +/// single birthday calendar on the backend, so they show on every client. /// -/// The sync is a **mirror**: it reconciles contact-sourced rows by -/// `external_uid` (adds new, updates changed, deletes removed) and never touches -/// manually added birthdays (which have no `external_uid`). The heavy lifting — -/// age suffix, cake icon, "notify N days before" reminder — is done server-side; -/// this only uploads name + date + birth year. +/// The sync is a **mirror**, scoped to THIS device: it reconciles rows whose +/// `external_uid` starts with `contact::` (adds new, updates changed, +/// deletes removed) and never touches other devices' rows or manually added +/// birthdays. Age suffix, cake icon and "notify N days before" are done +/// server-side; this only uploads name + date + birth year, and reports the +/// device so the web can list "birthdays come from these devices". enum BirthdaysImporter { - // MARK: – Persisted binding (which calendar receives Contacts birthdays) + // MARK: – Persisted state enum Key { - static let enabled = "birthdaysSyncEnabled" // Bool - static let calendarId = "birthdaysSyncCalendarId" // Int (0 = none) + static let enabled = "birthdaysSyncEnabled" // Bool + static let deviceId = "birthdaysDeviceId" // stable per-install UUID } static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) } - static var targetCalendarId: Int? { - let v = UserDefaults.standard.object(forKey: Key.calendarId) as? Int ?? 0 - return v > 0 ? v : nil - } static func setEnabled(_ on: Bool) { UserDefaults.standard.set(on, forKey: Key.enabled) } - static func setTargetCalendarId(_ id: Int?) { - UserDefaults.standard.set(id ?? 0, forKey: Key.calendarId) + + static var deviceId: String { + if let id = UserDefaults.standard.string(forKey: Key.deviceId) { return id } + let id = UUID().uuidString + UserDefaults.standard.set(id, forKey: Key.deviceId) + return id + } + + @MainActor static var deviceName: String { + let n = UIDevice.current.name + return n.isEmpty ? "iPhone" : n + } + + private static var appLang: String { + UserDefaults.standard.string(forKey: "appLanguage") ?? "system" } // MARK: – Contacts access @@ -55,7 +66,6 @@ enum BirthdaysImporter { return false } - /// Request Contacts access once. Returns true if usable for enumeration. @discardableResult static func requestAccess() async -> Bool { let status = CNContactStore.authorizationStatus(for: .contacts) @@ -72,11 +82,11 @@ enum BirthdaysImporter { // MARK: – Reading contact birthdays struct ContactBirthday { - let externalUid: String // "contact:" + let contactId: String let name: String let month: Int let day: Int - let year: Int? // nil = year unknown + let year: Int? } static func readContactBirthdays() throws -> [ContactBirthday] { @@ -101,54 +111,77 @@ enum BirthdaysImporter { guard !name.isEmpty else { return } let year = bday.year.flatMap { $0 > 0 ? $0 : nil } out.append(ContactBirthday( - externalUid: "contact:\(contact.identifier)", - name: name, month: m, day: d, year: year + contactId: contact.identifier, name: name, month: m, day: d, year: year )) } return out } + // MARK: – The single birthday calendar + + /// The user's birthday calendar, creating the single "Geburtstage" calendar + /// if none exists yet. + static func ensureBirthdayCalendar(api: CalendarrAPI) async -> LocalCalendar? { + let cals = (try? await api.getLocalCalendars()) ?? [] + if let existing = cals.first(where: { $0.isBirthday && $0.owned }) { return existing } + let name = L10n.t("birthday.calendar_name", appLang) + return try? await api.addLocalCalendar(name: name, color: "#E0407F", isBirthday: true) + } + + static func birthdayCalendar(api: CalendarrAPI) async -> LocalCalendar? { + let cals = (try? await api.getLocalCalendars()) ?? [] + return cals.first(where: { $0.isBirthday && $0.owned }) + } + // MARK: – Sync - /// Mirror the address book into the bound birthday calendar. No-op unless the - /// sync is enabled, a target calendar is set, and access is granted. + /// Mirror the address book into the birthday calendar (creating it if + /// needed). No-op unless enabled and Contacts access is granted. static func sync(api: CalendarrAPI) async { - guard isEnabled, let calId = targetCalendarId else { return } + guard isEnabled else { return } guard await requestAccess() else { return } guard let contacts = try? readContactBirthdays() else { return } - guard let existing = try? await api.getBirthdayEntries(calendarId: calId) else { return } + guard let cal = await ensureBirthdayCalendar(api: api) else { return } + guard let existing = try? await api.getBirthdayEntries(calendarId: cal.id) else { return } - // Only reconcile contact-sourced rows; leave manual entries untouched. + // Only reconcile THIS device's contact rows; leave other devices' + // rows and manual entries untouched. + let prefix = "contact:\(deviceId):" var byExt: [String: BirthdayEntry] = [:] - for e in existing { if let ext = e.externalUid { byExt[ext] = e } } + for e in existing { + if let ext = e.externalUid, ext.hasPrefix(prefix) { byExt[ext] = e } + } var seen = Set() for c in contacts { - seen.insert(c.externalUid) + let ext = prefix + c.contactId + seen.insert(ext) let (start, end) = allDayRange(month: c.month, day: c.day, year: c.year) - if let match = byExt[c.externalUid] { + if let match = byExt[ext] { let changed = match.title != c.name || match.month != c.month || match.day != c.day || match.birthYear != c.year if changed { try? await api.updateLocalEvent( uid: match.uid, title: c.name, start: start, end: end, isAllDay: true, location: "", description: "", color: nil, - rrule: "FREQ=YEARLY", externalUid: c.externalUid, - birthYear: c.year ?? -1 // -1 clears birth_year server-side + rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year ?? -1 ) } } else { _ = try? await api.createLocalEvent( - calendarId: calId, title: c.name, start: start, end: end, + calendarId: cal.id, title: c.name, start: start, end: end, isAllDay: true, location: "", description: "", color: nil, - rrule: "FREQ=YEARLY", externalUid: c.externalUid, birthYear: c.year + rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year ) } } - // Remove contact-sourced rows whose contact no longer has a birthday. for (ext, entry) in byExt where !seen.contains(ext) { try? await api.deleteLocalEvent(uid: entry.uid) } + + // Report this device so the web can list where birthdays come from. + let name = await deviceName + try? await api.reportBirthdaySync(deviceId: deviceId, deviceName: name, count: contacts.count) } /// All-day [start, end) for a birthday. Anchor year = birth year when known, diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index a52d95c..3d4dd03 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -153,10 +153,10 @@ class CalendarrAPI { /// Update a local calendar's birthday settings. `is_birthday` marks it as a /// birthday calendar; `notifyDaysBefore` sets the reminder (nil sends the -1 /// sentinel to clear it server-side). - func updateLocalCalendarBirthday(id: Int, isBirthday: Bool?, notifyDaysBefore: Int??) async throws { + func updateLocalCalendarBirthday(id: Int, isBirthday: Bool? = nil, notifyDaysBefore: Int? = nil) async throws { var body: [String: Any] = [:] if let b = isBirthday { body["is_birthday"] = b } - if let n = notifyDaysBefore { body["birthday_notify_days_before"] = n ?? -1 } + if let n = notifyDaysBefore { body["birthday_notify_days_before"] = n } // -1 clears server-side guard !body.isEmpty else { return } _ = try await request("/api/local/calendars/\(id)", method: "PUT", body: body) } @@ -168,6 +168,14 @@ class CalendarrAPI { return (try? JSONDecoder().decode([BirthdayEntry].self, from: data)) ?? [] } + /// Report that this device just synced its Contacts birthdays, so the web + /// can show "birthdays come from these devices". + func reportBirthdaySync(deviceId: String, deviceName: String, count: Int) async throws { + _ = try await request("/api/birthdays/sync-report", method: "POST", body: [ + "device_id": deviceId, "device_name": deviceName, "count": count, + ]) + } + func getICalSubscriptions() async throws -> [ICalSubscription] { let data = try await request("/api/ical/subscriptions") return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? [] diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 2aafd83..5ef84d1 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -26,8 +26,8 @@ struct AccountsView: View { // Contacts → birthday-calendar sync (opt-in, bound to one birthday calendar). @AppStorage("birthdaysSyncEnabled") private var birthdaysSyncEnabled = false - @AppStorage("birthdaysSyncCalendarId") private var birthdaysSyncCalendarId = 0 @State private var isSyncingBirthdays = false + @State private var birthdayNotify = -1 @AppStorage("appLanguage") private var appLang = "system" @@ -47,7 +47,13 @@ struct AccountsView: View { haSection } .onChange(of: birthdaysSyncEnabled) { _, on in - if on { Task { _ = await BirthdaysImporter.requestAccess() } } + if on { + Task { + _ = await BirthdaysImporter.requestAccess() + _ = await BirthdaysImporter.ensureBirthdayCalendar(api: api) + await load() + } + } } } } @@ -222,31 +228,30 @@ struct AccountsView: View { } } - private var birthdayCalendars: [LocalCalendar] { - localCalendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") } + /// The user's single birthday calendar (created on first sync/activation). + private var birthdayCalendar: LocalCalendar? { + localCalendars.first { $0.isBirthday && $0.owned } } @ViewBuilder var birthdayContactsSection: some View { Section { Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled) if birthdaysSyncEnabled { - if birthdayCalendars.isEmpty { - Text(L10n.t("birthday.contacts.need_calendar", appLang)) - .font(.caption).foregroundStyle(.secondary) - } else { - Picker(L10n.t("birthday.contacts.target", appLang), selection: $birthdaysSyncCalendarId) { - ForEach(birthdayCalendars) { c in Text(c.name).tag(c.id) } - } - Button { - Task { await syncBirthdays() } - } label: { - HStack { - Text(L10n.t("birthday.contacts.sync_now", appLang)) - if isSyncingBirthdays { Spacer(); ProgressView() } + if let cal = birthdayCalendar { + BirthdayNotifyPicker(days: $birthdayNotify, appLang: appLang) + .onChange(of: birthdayNotify) { _, v in + Task { try? await api.updateLocalCalendarBirthday(id: cal.id, notifyDaysBefore: v) } } - } - .disabled(isSyncingBirthdays || birthdaysSyncCalendarId == 0) } + Button { + Task { await syncBirthdays() } + } label: { + HStack { + Text(L10n.t("birthday.contacts.sync_now", appLang)) + if isSyncingBirthdays { Spacer(); ProgressView() } + } + } + .disabled(isSyncingBirthdays) } } header: { Text(L10n.t("birthday.contacts.header", appLang)) @@ -263,6 +268,7 @@ struct AccountsView: View { return } await BirthdaysImporter.sync(api: api) + await load() // pick up the (possibly newly created) birthday calendar infoMessage = L10n.t("birthday.contacts.synced", appLang) // Let the calendar refresh so imported birthdays show up right away. NotificationCenter.default.post(name: .manualSyncRequested, object: nil) @@ -474,10 +480,8 @@ struct AccountsView: View { CalendarStore.saveBanishedKeys(b) NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil) } - // Default the Contacts-sync target to the first birthday calendar. - if birthdaysSyncCalendarId == 0, let first = birthdayCalendars.first?.id { - birthdaysSyncCalendarId = first - } + // Reflect the birthday calendar's current reminder setting in the picker. + birthdayNotify = birthdayCalendar?.birthdayNotifyDaysBefore ?? -1 isLoading = false } @@ -595,8 +599,6 @@ struct AddLocalCalSheet: View { @State private var name = "" @State private var color = Color(hex: "#34a853") - @State private var isBirthday = false - @State private var notifyDays = -1 // -1 = off, 0 = on the day, N = days before @State private var isLoading = false @State private var error = "" @@ -607,14 +609,6 @@ struct AddLocalCalSheet: View { TextField(L10n.t("local.name", appLang), text: $name) ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false) } - Section { - Toggle(L10n.t("birthday.is_calendar", appLang), isOn: $isBirthday) - if isBirthday { - BirthdayNotifyPicker(days: $notifyDays, appLang: appLang) - } - } footer: { - if isBirthday { Text(L10n.t("birthday.is_calendar.desc", appLang)) } - } if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } } @@ -637,10 +631,7 @@ struct AddLocalCalSheet: View { private func save() async { isLoading = true do { - _ = try await api.addLocalCalendar( - name: name, color: color.toHex(), - isBirthday: isBirthday, - birthdayNotifyDaysBefore: (isBirthday && notifyDays >= 0) ? notifyDays : nil) + _ = try await api.addLocalCalendar(name: name, color: color.toHex()) await onDone() dismiss() } catch { self.error = error.localizedDescription } diff --git a/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift b/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift index c54293b..dc7784b 100644 --- a/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift @@ -1,8 +1,9 @@ import SwiftUI -/// Minimal "new birthday" mask: pick a birthday calendar, enter a name and a -/// date (day + month, optionally a year). Saves an all-day, yearly-recurring -/// local event; the server adds the age suffix and cake icon on read. +/// Minimal "new birthday" mask for the single birthday calendar: enter a name +/// and a date (optionally "year unknown"). Saves an all-day, yearly-recurring +/// local event; the server adds the age suffix and cake icon on read. If no +/// birthday calendar exists yet, offers to activate one. struct BirthdayEditorSheet: View { let api: CalendarrAPI var onDone: () async -> Void @@ -10,20 +11,16 @@ struct BirthdayEditorSheet: View { @AppStorage("appLanguage") private var appLang = "system" @Environment(\.dismiss) private var dismiss - @State private var calendars: [LocalCalendar] = [] - @State private var selectedCalId: Int? = nil + @State private var calendar: LocalCalendar? = nil @State private var name = "" @State private var date = Date() @State private var yearUnknown = false @State private var loading = true @State private var saving = false - - private var birthdayCalendars: [LocalCalendar] { - calendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") } - } + @State private var activating = false private var canSave: Bool { - !saving && selectedCalId != nil + !saving && calendar != nil && !name.trimmingCharacters(in: .whitespaces).isEmpty } @@ -32,9 +29,13 @@ struct BirthdayEditorSheet: View { Form { if loading { HStack { Spacer(); ProgressView(); Spacer() } - } else if birthdayCalendars.isEmpty { - Text(L10n.t("birthday.no_calendars", appLang)) - .foregroundStyle(.secondary) + } else if calendar == nil { + Section { + Text(L10n.t("birthday.activate_hint", appLang)) + .foregroundStyle(.secondary) + Button(L10n.t("birthday.activate", appLang)) { Task { await activate() } } + .disabled(activating) + } } else { Section(L10n.t("birthday.person", appLang)) { TextField(L10n.t("birthday.person_placeholder", appLang), text: $name) @@ -44,15 +45,6 @@ struct BirthdayEditorSheet: View { displayedComponents: [.date]) Toggle(L10n.t("birthday.year_unknown", appLang), isOn: $yearUnknown) } - if birthdayCalendars.count > 1 { - Section(L10n.t("birthday.contacts.target", appLang)) { - Picker(L10n.t("birthday.contacts.target", appLang), selection: $selectedCalId) { - ForEach(birthdayCalendars) { c in - Text(c.name).tag(Optional(c.id)) - } - } - } - } } } .navigationTitle(L10n.t("birthday.new", appLang)) @@ -71,16 +63,21 @@ struct BirthdayEditorSheet: View { } private func load() async { - calendars = (try? await api.getLocalCalendars()) ?? [] - if selectedCalId == nil { selectedCalId = birthdayCalendars.first?.id } + calendar = await BirthdaysImporter.birthdayCalendar(api: api) loading = false } + private func activate() async { + activating = true + calendar = await BirthdaysImporter.ensureBirthdayCalendar(api: api) + activating = false + } + private func save() async { - guard let calId = selectedCalId else { return } + guard let cal = calendar else { return } saving = true - let cal = Calendar.current - let comps = cal.dateComponents([.year, .month, .day], from: date) + let calc = Calendar.current + let comps = calc.dateComponents([.year, .month, .day], from: date) let month = comps.month ?? 1 let day = comps.day ?? 1 let year = yearUnknown ? nil : comps.year @@ -91,10 +88,10 @@ struct BirthdayEditorSheet: View { anchor.month = month anchor.day = day anchor.hour = 12 - let start = cal.date(from: anchor) ?? date - let end = cal.date(byAdding: .day, value: 1, to: start) ?? start + let start = calc.date(from: anchor) ?? date + let end = calc.date(byAdding: .day, value: 1, to: start) ?? start _ = try? await api.createLocalEvent( - calendarId: calId, title: name.trimmingCharacters(in: .whitespaces), + calendarId: cal.id, title: name.trimmingCharacters(in: .whitespaces), start: start, end: end, isAllDay: true, location: "", description: "", color: nil, rrule: "FREQ=YEARLY", birthYear: year ) diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 9e1b645..5412e5b 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -535,6 +535,9 @@ struct CalendarHostView: View { /// shows up without the user opening the filter sheet. private func syncFromServer(force: Bool = false) async { await SettingsSync.pull(api: api) + // Mirror Contacts birthdays on every server sync (manual, resume, + // periodic) — the user expects "sync with server" to include birthdays. + if BirthdaysImporter.isEnabled { await BirthdaysImporter.sync(api: api) } let changed = await store.reconcileCalendarVisibility(api: api) if changed || force { await forceReload() } } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 22be943..74c1a79 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -205,7 +205,9 @@ struct SettingsView: View { groupVisibleId = s.groupVisibleCalendarId ?? 0 } if let cals = try? await api.getLocalCalendars() { - ownLocalCals = cals.filter { $0.owned && !$0.group } + // A birthday calendar may be shared directly, but never stand in as + // the group-visible personal calendar. + ownLocalCals = cals.filter { $0.owned && !$0.group && !$0.isBirthday } } }