// // RemindersGroceryStore.swift // Nextcloud Cookbook iOS Client // import EventKit import Foundation import OSLog /// Maps a reminder's calendarItemIdentifier to its recipe context. struct ReminderMapping: Codable { let reminderIdentifier: String let recipeId: String let recipeName: String let itemName: String } /// Persisted mapping file: keyed by recipeId, each holding an array of reminder mappings. struct ReminderMappingStore: Codable { var recipes: [String: RecipeMappingEntry] = [:] } struct RecipeMappingEntry: Codable { let recipeName: String var mappings: [ReminderMapping] } @MainActor class RemindersGroceryStore { private let eventStore = EKEventStore() private(set) var groceryDict: [String: GroceryRecipe] = [:] var onDataChanged: (() -> Void)? private let dataStore = DataStore() private let mappingPath = "reminder_mappings.data" private var mappingStore = ReminderMappingStore() /// When true, the next `EKEventStoreChanged` notification is skipped because /// it was triggered by our own save. Prevents a race where `load()` reads stale /// mapping data from disk before `saveMappings()` finishes writing. private var ignoreNextExternalChange = false init() { NotificationCenter.default.addObserver( forName: .EKEventStoreChanged, object: eventStore, queue: .main ) { [weak self] _ in Task { @MainActor in guard let self else { return } if self.ignoreNextExternalChange { self.ignoreNextExternalChange = false return } await self.load() self.onDataChanged?() } } } deinit { NotificationCenter.default.removeObserver(self, name: .EKEventStoreChanged, object: eventStore) } // MARK: - Permission func checkPermissionStatus() -> EKAuthorizationStatus { EKEventStore.authorizationStatus(for: .reminder) } func requestAccess() async -> Bool { do { return try await eventStore.requestFullAccessToReminders() } catch { Logger.view.error("Failed to request Reminders access: \(error.localizedDescription)") return false } } // MARK: - Lists func availableReminderLists() -> [EKCalendar] { eventStore.calendars(for: .reminder) } private func targetCalendar() -> EKCalendar? { let identifier = UserSettings.shared.remindersListIdentifier if !identifier.isEmpty, let calendar = eventStore.calendar(withIdentifier: identifier) { return calendar } return eventStore.defaultCalendarForNewReminders() } // MARK: - Fetch Helper private nonisolated func fetchReminders(matching predicate: NSPredicate, in store: EKEventStore) async -> [EKReminder] { await withCheckedContinuation { continuation in store.fetchReminders(matching: predicate) { reminders in continuation.resume(returning: reminders ?? []) } } } // MARK: - CRUD func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil) { guard let calendar = targetCalendar() else { Logger.view.error("No target Reminders calendar available") return } let reminder = EKReminder(eventStore: eventStore) reminder.title = itemName reminder.calendar = calendar do { ignoreNextExternalChange = true try eventStore.save(reminder, commit: true) let name = recipeName ?? "-" addMapping(reminderIdentifier: reminder.calendarItemIdentifier, recipeId: recipeId, recipeName: name, itemName: itemName) appendToCache(itemName: itemName, recipeId: recipeId, recipeName: name) } catch { ignoreNextExternalChange = false Logger.view.error("Failed to save reminder: \(error.localizedDescription)") } } func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) { guard let calendar = targetCalendar() else { Logger.view.error("No target Reminders calendar available") return } let name = recipeName ?? "-" for item in items { let reminder = EKReminder(eventStore: eventStore) reminder.title = item reminder.calendar = calendar do { try eventStore.save(reminder, commit: false) addMapping(reminderIdentifier: reminder.calendarItemIdentifier, recipeId: recipeId, recipeName: name, itemName: item) appendToCache(itemName: item, recipeId: recipeId, recipeName: name) } catch { Logger.view.error("Failed to save reminder: \(error.localizedDescription)") } } do { ignoreNextExternalChange = true try eventStore.commit() } catch { ignoreNextExternalChange = false Logger.view.error("Failed to commit reminders: \(error.localizedDescription)") } saveMappings() } func deleteItem(_ itemName: String, fromRecipe recipeId: String) { // Find the reminder identifier from our mapping guard let entry = mappingStore.recipes[recipeId], let mapping = entry.mappings.first(where: { $0.itemName == itemName }) else { return } let identifier = mapping.reminderIdentifier // Find and remove the actual reminder guard let calendar = targetCalendar() else { return } let predicate = eventStore.predicateForReminders(in: [calendar]) let store = eventStore Task { let reminders = await fetchReminders(matching: predicate, in: store) for reminder in reminders where reminder.calendarItemIdentifier == identifier { do { self.ignoreNextExternalChange = true try self.eventStore.remove(reminder, commit: true) } catch { self.ignoreNextExternalChange = false Logger.view.error("Failed to remove reminder: \(error.localizedDescription)") } break } self.removeMapping(reminderIdentifier: identifier, recipeId: recipeId) self.removeFromCache(itemName: itemName, recipeId: recipeId) } } func deleteGroceryRecipe(_ recipeId: String) { guard let entry = mappingStore.recipes[recipeId] else { return } let identifiers = Set(entry.mappings.map(\.reminderIdentifier)) guard let calendar = targetCalendar() else { return } let predicate = eventStore.predicateForReminders(in: [calendar]) let store = eventStore Task { let reminders = await fetchReminders(matching: predicate, in: store) for reminder in reminders where identifiers.contains(reminder.calendarItemIdentifier) { do { try self.eventStore.remove(reminder, commit: false) } catch { Logger.view.error("Failed to remove reminder: \(error.localizedDescription)") } } do { self.ignoreNextExternalChange = true try self.eventStore.commit() } catch { self.ignoreNextExternalChange = false Logger.view.error("Failed to commit reminder removal: \(error.localizedDescription)") } self.mappingStore.recipes.removeValue(forKey: recipeId) self.saveMappings() self.groceryDict.removeValue(forKey: recipeId) self.onDataChanged?() } } func deleteAll() { let allIdentifiers = Set(mappingStore.recipes.values.flatMap { $0.mappings.map(\.reminderIdentifier) }) guard !allIdentifiers.isEmpty else { return } guard let calendar = targetCalendar() else { return } let predicate = eventStore.predicateForReminders(in: [calendar]) let store = eventStore Task { let reminders = await fetchReminders(matching: predicate, in: store) for reminder in reminders where allIdentifiers.contains(reminder.calendarItemIdentifier) { do { try self.eventStore.remove(reminder, commit: false) } catch { Logger.view.error("Failed to remove reminder: \(error.localizedDescription)") } } do { self.ignoreNextExternalChange = true try self.eventStore.commit() } catch { self.ignoreNextExternalChange = false Logger.view.error("Failed to commit reminder removal: \(error.localizedDescription)") } self.mappingStore.recipes = [:] self.saveMappings() self.groceryDict = [:] self.onDataChanged?() } } func containsItem(at recipeId: String, item: String) -> Bool { guard let recipe = groceryDict[recipeId] else { return false } return recipe.items.contains(where: { $0.name == item }) } func containsRecipe(_ recipeId: String) -> Bool { groceryDict[recipeId] != nil } // MARK: - Load / Sync func load() async { // Load the local mapping first if let stored: ReminderMappingStore = try? await dataStore.load(fromPath: mappingPath) { mappingStore = stored } guard checkPermissionStatus() == .fullAccess else { return } guard let calendar = targetCalendar() else { return } let predicate = eventStore.predicateForReminders(in: [calendar]) let store = eventStore let reminders = await fetchReminders(matching: predicate, in: store) // Build a set of live reminder identifiers for cleanup let liveIdentifiers = Set(reminders.map(\.calendarItemIdentifier)) // Prune mappings for reminders that no longer exist (deleted externally) var pruned = false for (recipeId, entry) in mappingStore.recipes { let before = entry.mappings.count let filtered = entry.mappings.filter { liveIdentifiers.contains($0.reminderIdentifier) } if filtered.isEmpty { mappingStore.recipes.removeValue(forKey: recipeId) pruned = true } else if filtered.count != before { mappingStore.recipes[recipeId] = RecipeMappingEntry(recipeName: entry.recipeName, mappings: filtered) pruned = true } } if pruned { saveMappings() } // Rebuild groceryDict from mappings (source of truth for grouping) var dict: [String: GroceryRecipe] = [:] for (recipeId, entry) in mappingStore.recipes { let items = entry.mappings.map { GroceryRecipeItem($0.itemName) } dict[recipeId] = GroceryRecipe(name: entry.recipeName, items: items) } groceryDict = dict } // MARK: - Mapping Persistence private func addMapping(reminderIdentifier: String, recipeId: String, recipeName: String, itemName: String) { let mapping = ReminderMapping( reminderIdentifier: reminderIdentifier, recipeId: recipeId, recipeName: recipeName, itemName: itemName ) if mappingStore.recipes[recipeId] != nil { mappingStore.recipes[recipeId]?.mappings.append(mapping) } else { mappingStore.recipes[recipeId] = RecipeMappingEntry(recipeName: recipeName, mappings: [mapping]) } saveMappings() } private func removeMapping(reminderIdentifier: String, recipeId: String) { guard var entry = mappingStore.recipes[recipeId] else { return } entry.mappings.removeAll { $0.reminderIdentifier == reminderIdentifier } if entry.mappings.isEmpty { mappingStore.recipes.removeValue(forKey: recipeId) } else { mappingStore.recipes[recipeId] = entry } saveMappings() } private func saveMappings() { Task { await dataStore.save(data: mappingStore, toPath: mappingPath) } } // MARK: - Cache Helpers private func appendToCache(itemName: String, recipeId: String, recipeName: String) { if groceryDict[recipeId] != nil { groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName)) } else { groceryDict[recipeId] = GroceryRecipe(name: recipeName, item: GroceryRecipeItem(itemName)) } } private func removeFromCache(itemName: String, recipeId: String) { guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return } groceryDict[recipeId]?.items.remove(at: itemIndex) if groceryDict[recipeId]?.items.isEmpty == true { groceryDict.removeValue(forKey: recipeId) } } }