import SwiftUI import EventKit import AppKit /// Placeholder title is configurable via state (see `placeholderTitle`) private let SAME_TIME_TOL_MIN: Double = 5 private let SKIP_ALL_DAY_DEFAULT = true enum OverlapMode: String, CaseIterable, Identifiable, Codable { case allow, skipCovered, fillGaps var id: String { rawValue } } // Calendar label helper to disambiguate identical names private func calLabel(_ cal: EKCalendar) -> String { let src = cal.source.title return src.isEmpty ? cal.title : "\(cal.title) — \(src)" } // Calendar color helpers private func calColor(_ cal: EKCalendar) -> Color { #if os(macOS) return Color(cal.cgColor ?? NSColor.systemGray.cgColor) #else return Color(cgColor: cal.cgColor ?? UIColor.systemGray.cgColor) #endif } @ViewBuilder private func calChip(_ cal: EKCalendar) -> some View { HStack(spacing: 6) { Circle().fill(calColor(cal)).frame(width: 10, height: 10) Text(calLabel(cal)) } } // Remove our prefix when building titles so it never doubles up func stripPrefix(_ title: String?, prefix: String) -> String { guard let t = title else { return "" } if prefix.isEmpty { return t } return t.hasPrefix(prefix) ? String(t.dropFirst(prefix.count)) : t } // De-dup blocks by occurrence (preferred) or by time range func uniqueBlocks(_ blocks: [Block], trackByID: Bool) -> [Block] { var seen = Set() var out: [Block] = [] for b in blocks { let key: String if trackByID, let sid = b.srcEventID { let occ = b.occurrence?.timeIntervalSince1970 ?? b.start.timeIntervalSince1970 key = "id|\(sid)|\(occ)" } else { key = "t|\(b.start.timeIntervalSince1970)|\(b.end.timeIntervalSince1970)" } if seen.insert(key).inserted { out.append(b) } } return out } // Parse mirror URL: mirror://||||| private func parseMirrorURL(_ url: URL?) -> (srcEventID: String?, occ: Date?, start: Date?, end: Date?) { guard let abs = url?.absoluteString, abs.hasPrefix("mirror://") else { return (nil, nil, nil, nil) } let body = abs.dropFirst("mirror://".count) let parts = body.split(separator: "|") var srcID: String? = nil var occDate: Date? = nil var sDate: Date? = nil var eDate: Date? = nil if parts.count >= 3 { srcID = String(parts[2]) } if parts.count >= 4, let ts = TimeInterval(String(parts[3])) { occDate = Date(timeIntervalSince1970: ts) } if parts.count >= 6, let sTS = TimeInterval(String(parts[4])), let eTS = TimeInterval(String(parts[5])) { sDate = Date(timeIntervalSince1970: sTS) eDate = Date(timeIntervalSince1970: eTS) } return (srcID, occDate, sDate, eDate) } // Recognize a mirrored placeholder even if URL is missing private func isMirrorEvent(_ ev: EKEvent, prefix: String, placeholder: String) -> Bool { if ev.url?.absoluteString.hasPrefix("mirror://") ?? false { return true } let t = ev.title ?? "" if !prefix.isEmpty && t.hasPrefix(prefix) { return true } if t == placeholder || (!prefix.isEmpty && t == (prefix + placeholder)) { return true } return false } struct Block: Hashable { let start: Date let end: Date let srcEventID: String? // for reschedule tracking let label: String? // source title (for dry-run / non-private) let notes: String? // source notes (for optional copy) let occurrence: Date? // occurrenceDate for recurring instances } struct Route: Identifiable, Hashable, Codable { let id = UUID() var sourceID: String var targetIDs: Set var privacy: Bool // true = hide details for this source var copyNotes: Bool // copy description when privacy is OFF var mergeGapHours: Int // per-route merge gap (hours) var overlap: OverlapMode // per-route overlap behavior var allDay: Bool // per-route mirror all-day } struct ContentView: View { @State private var store = EKEventStore() @State private var hasAccess = false @State private var calendars: [EKCalendar] = [] @State private var sourceIndex: Int = 0 @State private var targetSelections = Set() // indices in calendars // Stable selection storage by persistent identifiers (survives reordering) @State private var sourceID: String? = nil @State private var targetIDs = Set() @State private var routes: [Route] = [] @AppStorage("daysForward") private var daysForward: Int = 7 @AppStorage("daysBack") private var daysBack: Int = 1 @State private var mergeGapMin: Int = 0 @AppStorage("mergeGapHours") private var mergeGapHours: Int = 0 @AppStorage("hideDetails") private var hideDetails: Bool = true // Privacy ON by default -> use "Busy" @AppStorage("copyDescription") private var copyDescription: Bool = false // Only applies when hideDetails == false @AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false @AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue @AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false @AppStorage("workHoursStart") private var workHoursStart: Int = 9 @AppStorage("workHoursEnd") private var workHoursEnd: Int = 17 @AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = "" @AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false var overlapMode: OverlapMode { get { OverlapMode(rawValue: overlapModeRaw) ?? .allow } nonmutating set { overlapModeRaw = newValue.rawValue } } @State private var writeEnabled = false // dry-run unless checked @State private var logText = "Ready." @State private var isRunning = false @State private var isCLIRun = false @State private var confirmCleanup = false // Run-session guard: prevents the same source event from being mirrored // into the same target more than once across multiple routes within a // single "Mirror Now" click. @State private var sessionGuard = Set() @AppStorage("titlePrefix") private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders @AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy" // global customizable placeholder title @AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true // delete mirrors whose source instance no longer exists private static let intFormatter: NumberFormatter = { let f = NumberFormatter() f.numberStyle = .none f.minimum = 0 f.maximum = 720 return f }() private static let hourFormatter: NumberFormatter = { let f = NumberFormatter() f.numberStyle = .none f.minimum = 0 f.maximum = 24 return f }() // Deterministic ordering to keep indices stable across runs private func sortedCalendars(_ cals: [EKCalendar]) -> [EKCalendar] { return cals.sorted { a, b in if a.source.title != b.source.title { return a.source.title < b.source.title } if a.title != b.title { return a.title < b.title } return a.calendarIdentifier < b.calendarIdentifier } } private var excludedTitleFilterList: [String] { excludedTitleFiltersRaw .split { $0 == "\n" || $0 == "," } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } private var excludedTitleFilterTerms: [String] { excludedTitleFilterList.map { $0.lowercased() } } private func rebuildSelectionsFromIDs() { // Map IDs -> indices in current calendars var idToIndex: [String:Int] = [:] for (i, c) in calendars.enumerated() { idToIndex[c.calendarIdentifier] = i } // Restore source index from sourceID if possible if let sid = sourceID, let idx = idToIndex[sid] { sourceIndex = idx } else if !calendars.isEmpty { sourceIndex = min(sourceIndex, calendars.count - 1); sourceID = calendars[sourceIndex].calendarIdentifier } // Restore targets from IDs let restored = targetIDs.compactMap { idToIndex[$0] } targetSelections = Set(restored).filter { $0 != sourceIndex } } private func indexForCalendar(id: String) -> Int? { calendars.firstIndex(where: { $0.calendarIdentifier == id }) } private func labelForCalendar(id: String) -> String { calendars.first(where: { $0.calendarIdentifier == id }).map(calLabel) ?? id } // Ensure the currently selected source calendar is not present in targets private func enforceNoSourceInTargets() { guard sourceIndex < calendars.count else { return } let sid = calendars[sourceIndex].calendarIdentifier // Remove by index if targetSelections.contains(sourceIndex) { targetSelections.remove(sourceIndex) } // Remove by ID (in case indices shifted) targetIDs.remove(sid) } // MARK: - Extracted UI sections to simplify type-checking @ViewBuilder private func calendarsSection() -> some View { VStack(alignment: .leading, spacing: 8) { Text("Source calendar") Picker("Source", selection: $sourceIndex) { ForEach(Array(calendars.indices), id: \.self) { i in Text("\(i+1): \(calLabel(calendars[i]))").tag(i) } } .pickerStyle(.menu) .frame(maxWidth: 360) .disabled(isRunning || calendars.isEmpty) Text("Target calendars (check one or more)") ScrollView { VStack(alignment: .leading, spacing: 6) { ForEach(Array(calendars.indices), id: \.self) { i in let isSource = (i == sourceIndex) let binding = Binding( get: { !isSource && targetSelections.contains(i) }, set: { newValue in // Never allow selecting the source as a target if isSource { return } if newValue { targetSelections.insert(i) targetIDs.insert(calendars[i].calendarIdentifier) } else { targetSelections.remove(i) targetIDs.remove(calendars[i].calendarIdentifier) } } ) Toggle(isOn: binding) { HStack { Text("\(i+1):"); calChip(calendars[i]) } } .disabled(isRunning || isSource) } } } .frame(height: 180) } } @ViewBuilder private func routesSection() -> some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("Routes (multi-source)").font(.headline) Spacer() Button("Add from current selection") { guard let sid = sourceID, !targetIDs.isEmpty else { return } let r = Route(sourceID: sid, targetIDs: targetIDs, privacy: hideDetails, copyNotes: copyDescription, mergeGapHours: mergeGapHours, overlap: overlapMode, allDay: mirrorAllDay) routes.append(r) } .disabled(isRunning || calendars.isEmpty || targetIDs.isEmpty) Button("Clear") { routes.removeAll() } .disabled(isRunning || routes.isEmpty) } if routes.isEmpty { Text("No routes yet. Pick a Source and Targets above, then click ‘Add from current selection’.") .foregroundStyle(.secondary) } else { ForEach($routes, id: \.id) { routeBinding in routeCard(for: routeBinding) } } } } @ViewBuilder private func routeCard(for routeBinding: Binding) -> some View { let route = routeBinding.wrappedValue VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 8) { sourceSummaryView(for: route) Text("→ Targets:") targetSummaryView(for: route) Spacer() Toggle("Private", isOn: routeBinding.privacy) .help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).") separatorDot() mergeGapField(for: routeBinding) Toggle("Copy desc", isOn: routeBinding.copyNotes) .disabled(isRunning || route.privacy) .help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.") overlapPicker(for: routeBinding) .disabled(isRunning) Toggle("All-day", isOn: routeBinding.allDay) .disabled(isRunning) .help("Mirror all-day events for this source.") separatorDot() Button(role: .destructive) { removeRoute(id: route.id) } label: { Text("Remove") } } } .padding(6) .background(.quaternary.opacity(0.1)) .cornerRadius(6) } @ViewBuilder private func sourceSummaryView(for route: Route) -> some View { Text("Source:") if let sCal = calendars.first(where: { $0.calendarIdentifier == route.sourceID }) { HStack(spacing: 6) { Circle().fill(calColor(sCal)).frame(width: 10, height: 10) Text(calLabel(sCal)).bold() } } else { Text(labelForCalendar(id: route.sourceID)).bold() } } @ViewBuilder private func targetSummaryView(for route: Route) -> some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(route.targetIDs.sorted(by: <), id: \.self) { tid in if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) { HStack(spacing: 6) { Circle().fill(calColor(tCal)).frame(width: 10, height: 10) Text(calLabel(tCal)) } } else { Text(labelForCalendar(id: tid)) } } } } .frame(maxWidth: 420) } @ViewBuilder private func mergeGapField(for routeBinding: Binding) -> some View { HStack(spacing: 6) { Text("Merge gap:") TextField("0", value: routeBinding.mergeGapHours, formatter: Self.intFormatter) .frame(width: 48) .disabled(isRunning) .help("Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge.") Text("h").foregroundStyle(.secondary) } } @ViewBuilder private func overlapPicker(for routeBinding: Binding) -> some View { HStack(spacing: 6) { Text("Overlap:") Picker("Overlap", selection: routeBinding.overlap) { ForEach(OverlapMode.allCases) { mode in Text(mode.rawValue).tag(mode) } } .frame(width: 160) .help("allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block.") } } @ViewBuilder private func separatorDot() -> some View { Text("·").foregroundStyle(.secondary) } private func removeRoute(id: UUID) { routes.removeAll { $0.id == id } } @ViewBuilder private func optionsSection() -> some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 12) { Text("Days back:") TextField("1", value: $daysBack, formatter: Self.intFormatter) .frame(width: 60) .disabled(isRunning) Text("Days forward:") TextField("7", value: $daysForward, formatter: Self.intFormatter) .frame(width: 60) .disabled(isRunning) } .onChange(of: daysBack) { v in daysBack = max(0, v) } .onChange(of: daysForward) { v in daysForward = max(0, v) } HStack(spacing: 8) { Text("Default merge gap:") TextField("0", value: $mergeGapHours, formatter: Self.intFormatter) .frame(width: 60) .disabled(isRunning) Text("hours").foregroundStyle(.secondary) } .onChange(of: mergeGapHours) { newVal in mergeGapMin = max(0, newVal * 60) } Toggle("Hide details (use \"Busy\" title)", isOn: $hideDetails) .disabled(isRunning) Toggle("Copy description when mirroring", isOn: $copyDescription) .disabled(isRunning || hideDetails) Toggle("Mirror all-day events", isOn: $mirrorAllDay) .disabled(isRunning) Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly) .disabled(isRunning) Picker("Overlap mode", selection: $overlapModeRaw) { ForEach(OverlapMode.allCases) { mode in Text(mode.rawValue).tag(mode.rawValue) } } .pickerStyle(.segmented) .disabled(isRunning) HStack(spacing: 8) { Text("Title prefix:") TextField("🪞 ", text: $titlePrefix) .frame(width: 72) .disabled(isRunning) Text("(left blank for none)").foregroundStyle(.secondary) } // Insert placeholder title UI HStack(spacing: 8) { Text("Placeholder title:") TextField("Busy", text: $placeholderTitle) .frame(width: 160) .disabled(isRunning) } Toggle("Limit mirroring to work hours", isOn: $filterByWorkHours) .disabled(isRunning) .onChange(of: filterByWorkHours) { _ in saveSettingsToDefaults() } if filterByWorkHours { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Text("Start hour:") TextField("9", value: $workHoursStart, formatter: Self.hourFormatter) .frame(width: 48) .disabled(isRunning) Text("End hour:") TextField("17", value: $workHoursEnd, formatter: Self.hourFormatter) .frame(width: 48) .disabled(isRunning) Text("(local time)").foregroundStyle(.secondary) } Text("Events starting outside this range are skipped; end hour is exclusive.") .foregroundStyle(.secondary) .font(.footnote) } .onChange(of: workHoursStart) { _ in clampWorkHours() saveSettingsToDefaults() } .onChange(of: workHoursEnd) { _ in clampWorkHours() saveSettingsToDefaults() } } VStack(alignment: .leading, spacing: 6) { Text("Skip source titles (one per line)") TextEditor(text: $excludedTitleFiltersRaw) .font(.body) .frame(minHeight: 80) .disabled(isRunning) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.secondary.opacity(0.3)) ) Text("Matches are case-insensitive and apply before mirroring.") .foregroundStyle(.secondary) .font(.footnote) } .onChange(of: excludedTitleFiltersRaw) { _ in saveSettingsToDefaults() } Toggle("Write to calendars (disable for Dry-Run)", isOn: $writeEnabled) .disabled(isRunning) // Insert auto-delete toggle after writeEnabled Toggle("Auto-delete mirrors if source is removed", isOn: $autoDeleteMissing) .disabled(isRunning) HStack(spacing: 12) { Button("Export Settings…") { exportSettings() } Button("Import Settings…") { importSettings() } } .padding(.vertical, 4) HStack { Button(isRunning ? "Running…" : "Mirror Now") { Task { // New click -> reset the guard so we don't re-process sessionGuard.removeAll() if routes.isEmpty { await runMirror() } else { for r in routes { // Resolve source index and target IDs for this route if let sIdx = indexForCalendar(id: r.sourceID) { // Save globals let prevPrivacy = hideDetails let prevCopy = copyDescription let prevGapH = mergeGapHours let prevGapM = mergeGapMin let prevOverlap = overlapMode let prevAllDay = mirrorAllDay // Apply per-route state changes on MainActor await MainActor.run { sourceIndex = sIdx sourceID = r.sourceID targetIDs = r.targetIDs // Belt-and-suspenders: ensure source is not in targets even if UI state is stale targetIDs.remove(r.sourceID) hideDetails = r.privacy copyDescription = r.copyNotes mergeGapHours = max(0, r.mergeGapHours) mergeGapMin = mergeGapHours * 60 overlapModeRaw = r.overlap.rawValue mirrorAllDay = r.allDay } await runMirror() await MainActor.run { // Restore globals hideDetails = prevPrivacy copyDescription = prevCopy mergeGapHours = prevGapH mergeGapMin = prevGapM overlapModeRaw = prevOverlap.rawValue mirrorAllDay = prevAllDay } } } } } } .disabled(isRunning || targetSelections.isEmpty || calendars.isEmpty) Button("Cleanup Placeholders") { if writeEnabled { // Real delete: ask for confirmation first confirmCleanup = true } else { // Dry-run: run without confirmation Task { if routes.isEmpty { await runCleanup() } else { for r in routes { if let sIdx = indexForCalendar(id: r.sourceID) { await MainActor.run { sourceIndex = sIdx sourceID = r.sourceID targetIDs = r.targetIDs } await runCleanup() } } } } } } .disabled(isRunning) Button("Refresh Calendars") { reloadCalendars() } .disabled(isRunning) Spacer() } } } @ViewBuilder private func logSection() -> some View { Text("Log") TextEditor(text: $logText) .font(.system(.body, design: .monospaced)) .frame(minHeight: 180) .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary)) } var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("BusyMirror").font(.title2).bold() Spacer() Button(hasAccess ? "Recheck Permission" : "Request Calendar Access") { requestAccess() } .disabled(isRunning) } Divider() if !hasAccess { Text("Calendar access not granted yet. Click “Request Calendar Access”.") .foregroundStyle(.secondary) } else { HStack(alignment: .top, spacing: 24) { calendarsSection() optionsSection() } routesSection() logSection() } } .padding(16) .confirmationDialog( "Delete mirrored placeholders?", isPresented: $confirmCleanup, titleVisibility: .visible ) { Button("Delete now", role: .destructive) { Task { if routes.isEmpty { await runCleanup() } else { for r in routes { if let sIdx = indexForCalendar(id: r.sourceID) { await MainActor.run { sourceIndex = sIdx sourceID = r.sourceID targetIDs = r.targetIDs } await runCleanup() } } } } } Button("Cancel", role: .cancel) {} } message: { Text("This will remove events identified as mirrored (by URL prefix or title prefix ‘\(titlePrefix)’) within the current window (Days back/forward) from the selected target calendars.") } .onAppear { requestAccess() loadSettingsFromDefaults() mergeGapMin = max(0, mergeGapHours * 60) tryRunCLIIfPresent() enforceNoSourceInTargets() } .onChange(of: sourceIndex) { newValue in // Track selected source by persistent ID and ensure it is not a target if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier } enforceNoSourceInTargets() } .onChange(of: targetSelections) { _ in // If the new source is accidentally included, drop it enforceNoSourceInTargets() } .onChange(of: targetIDs) { _ in // If IDs contain the source’s ID, drop it enforceNoSourceInTargets() } .onChange(of: routes) { _ in saveSettingsToDefaults() } } // MARK: - CLI support func tryRunCLIIfPresent() { let args = CommandLine.arguments guard let routesIdx = args.firstIndex(of: "--routes") else { return } isCLIRun = true func boolArg(_ name: String, default def: Bool) -> Bool { if let i = args.firstIndex(of: name), i+1 < args.count { let v = args[i+1].lowercased() return v == "1" || v == "true" || v == "yes" || v == "on" } return def } func intArg(_ name: String, default def: Int) -> Int { if let i = args.firstIndex(of: name), i+1 < args.count, let n = Int(args[i+1]) { return n } return def } func strArg(_ name: String) -> String? { if let i = args.firstIndex(of: name), i+1 < args.count { return args[i+1] } return nil } // Configure options from CLI flags hideDetails = boolArg("--privacy", default: hideDetails) copyDescription = boolArg("--copy-notes", default: copyDescription) writeEnabled = boolArg("--write", default: writeEnabled) mirrorAllDay = boolArg("--all-day", default: mirrorAllDay) daysForward = intArg("--days-forward", default: daysForward) daysBack = intArg("--days-back", default: daysBack) mergeGapHours = intArg("--merge-gap-hours", default: mergeGapHours) mergeGapMin = max(0, mergeGapHours * 60) if let modeStr = strArg("--mode")?.lowercased() { switch modeStr { case "allow": overlapModeRaw = OverlapMode.allow.rawValue case "skipcovered", "skip": overlapModeRaw = OverlapMode.skipCovered.rawValue case "fillgaps", "gaps": overlapModeRaw = OverlapMode.fillGaps.rawValue default: break } } let routesSpec = (routesIdx+1 < args.count) ? args[routesIdx+1] : "" let routeParts = routesSpec.split(separator: ";").map { $0.trimmingCharacters(in: .whitespaces) } log("CLI: routes=\(routesSpec)") Task { // Wait up to ~10s for calendars to load for _ in 0..<50 { if hasAccess && !calendars.isEmpty { break } try? await Task.sleep(nanoseconds: 200_000_000) } guard hasAccess, !calendars.isEmpty else { log("CLI: no calendar access; aborting") NSApp.terminate(nil) return } for part in routeParts where !part.isEmpty { // Format: "S->T1,T2,T3" (indices are 1-based as shown in UI) let lr = part.split(separator: "->", maxSplits: 1).map { String($0) } guard lr.count == 2, let s1 = Int(lr[0].trimmingCharacters(in: .whitespaces)) else { continue } let srcIdx0 = max(0, s1 - 1) let tgtIdxs0: [Int] = lr[1].split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces))?.advanced(by: -1) }.filter { $0 >= 0 } if srcIdx0 >= calendars.count { continue } await MainActor.run { sourceIndex = srcIdx0 sourceID = calendars[srcIdx0].calendarIdentifier targetSelections = Set(tgtIdxs0) targetIDs = Set(tgtIdxs0.compactMap { i in calendars.indices.contains(i) ? calendars[i].calendarIdentifier : nil }) } if boolArg("--cleanup-only", default: false) { log("CLI: cleanup route \(part)") await runCleanup() } else { log("CLI: mirror route \(part)") await runMirror() } } if CommandLine.arguments.contains("--exit") || isCLIRun { NSApp.terminate(nil) } } } // MARK: - Permissions & Calendars @MainActor func requestAccess() { log("Requesting calendar access…") if #available(macOS 14.0, *) { store.requestFullAccessToEvents { granted, _ in DispatchQueue.main.async { hasAccess = granted if granted { // Reinitialize the store after permission changes to ensure sources load store = EKEventStore() reloadCalendars() } log(granted ? "Access granted." : "Access denied.") } } } else { store.requestAccess(to: .event) { granted, _ in DispatchQueue.main.async { hasAccess = granted if granted { // Reinitialize the store after permission changes to ensure sources load store = EKEventStore() reloadCalendars() } log(granted ? "Access granted." : "Access denied.") } } } } @MainActor func reloadCalendars() { let fetched = store.calendars(for: .event) calendars = sortedCalendars(fetched) // Initialize IDs on first load if sourceID == nil, let first = calendars.first { sourceID = first.calendarIdentifier } // Rebuild index-based selections from stored IDs rebuildSelectionsFromIDs() log("Loaded \(calendars.count) calendars.") } // MARK: - Mirror engine (EventKit) func runMirror() async { guard hasAccess, !calendars.isEmpty else { return } isRunning = true defer { isRunning = false } let srcCal = calendars[sourceIndex] // Ensure sourceID is set when we start sourceID = srcCal.calendarIdentifier // Extra safety: drop source from targets before computing them enforceNoSourceInTargets() let srcName = calLabel(srcCal) // Build targets by identifier to be robust against index/order changes let targetSet = Set(targetIDs) // Additional guard: log and strip if source sneaks into targets if targetSet.contains(srcCal.calendarIdentifier) { log("- WARN: source is present in targets, removing: \(srcName)") } let targetSetNoSrc = targetSet.subtracting([srcCal.calendarIdentifier]) let targets = calendars.filter { targetSetNoSrc.contains($0.calendarIdentifier) } let cal = Calendar.current let todayStart = cal.startOfDay(for: Date()) let windowStart = cal.date(byAdding: .day, value: -daysBack, to: todayStart)! let windowEnd = cal.date(byAdding: .day, value: daysForward, to: todayStart)! log("=== BusyMirror ===") log("Source: \(srcName) Targets: \(targets.map { calLabel($0) }.joined(separator: ", "))") log("Window: \(windowStart) -> \(windowEnd)") log("WRITE: \(writeEnabled) \(writeEnabled ? "" : "(DRY-RUN)") mode: \(overlapMode.rawValue) mergeGapMin: \(mergeGapMin) allDay: \(mirrorAllDay)") log("Route: \(srcName) → {\(targets.map { calLabel($0) }.joined(separator: ", "))}") // Source events (recurrences expanded by EventKit) let srcPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [srcCal]) var srcEvents = store.events(matching: srcPred) let srcFetched = srcEvents.count // HARD FILTER: even if EventKit returns events from other calendars, keep only exact source calendar srcEvents = srcEvents.filter { $0.calendar.calendarIdentifier == srcCal.calendarIdentifier } let srcKept = srcEvents.count if srcKept != srcFetched { log("- WARN: filtered \(srcFetched - srcKept) stray source event(s) not in \(srcName)") } srcEvents.sort { ($0.startDate ?? .distantPast) < ($1.startDate ?? .distantPast) } var srcBlocks: [Block] = [] var skippedMirrors = 0 let titleFilters = excludedTitleFilterTerms let enforceWorkHours = filterByWorkHours && workHoursEnd > workHoursStart let allowedStartMinutes = workHoursStart * 60 let allowedEndMinutes = workHoursEnd * 60 var skippedWorkHours = 0 var skippedTitles = 0 var skippedStatus = 0 for ev in srcEvents { if mirrorAcceptedOnly, ev.hasAttendees { // Only include events where the current user's attendee status is Accepted let attendees = ev.attendees ?? [] if let me = attendees.first(where: { $0.isCurrentUser }) { if me.participantStatus != .accepted { skippedStatus += 1 continue } } else { // If we cannot determine a self attendee, treat as not accepted skippedStatus += 1 continue } } if enforceWorkHours, !ev.isAllDay, let start = ev.startDate, isOutsideWorkHours(start, calendar: cal, startMinutes: allowedStartMinutes, endMinutes: allowedEndMinutes) { skippedWorkHours += 1 continue } if shouldSkip(event: ev, filters: titleFilters) { skippedTitles += 1 continue } if SKIP_ALL_DAY_DEFAULT == true && mirrorAllDay == false && ev.isAllDay { continue } if isMirrorEvent(ev, prefix: titlePrefix, placeholder: placeholderTitle) { // Aggregate skip count for mirrored-on-source skippedMirrors += 1 continue } guard let s = ev.startDate, let e = ev.endDate, e > s else { continue } // Defensive: never treat events from another calendar as source guard ev.calendar.calendarIdentifier == srcCal.calendarIdentifier else { continue } let srcID = ev.eventIdentifier // stable across launches unless event is deleted srcBlocks.append(Block(start: s, end: e, srcEventID: srcID, label: ev.title, notes: ev.notes, occurrence: ev.occurrenceDate)) } if skippedMirrors > 0 { log("- SKIP mirrored-on-source: \(skippedMirrors) instance(s)") } if skippedWorkHours > 0 { log("- SKIP outside work hours: \(skippedWorkHours) event(s)") } if skippedTitles > 0 { log("- SKIP title filter: \(skippedTitles) event(s)") } if skippedStatus > 0 { log("- SKIP non-accepted status: \(skippedStatus) event(s)") } // Deduplicate source blocks to avoid duplicates from EventKit (recurrences / sync races) srcBlocks = uniqueBlocks(srcBlocks, trackByID: mergeGapMin == 0) // Merge for Flights or similar let baseBlocks = (mergeGapMin > 0) ? mergeBlocks(srcBlocks, gapMinutes: mergeGapMin) : srcBlocks let trackByID = (mergeGapMin == 0) for tgt in targets { let tgtName = calLabel(tgt) log(">>> Target: \(tgtName)") if tgt.calendarIdentifier == srcCal.calendarIdentifier { log("- SKIP target is same as source: \(tgtName)") continue } // Prefetch target window let tgtPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [tgt]) var tgtEvents = store.events(matching: tgtPred) let tgtFetched = tgtEvents.count // HARD FILTER: ensure we only consider events truly on the target calendar tgtEvents = tgtEvents.filter { $0.calendar.calendarIdentifier == tgt.calendarIdentifier } if tgtFetched != tgtEvents.count { log("- WARN: filtered \(tgtFetched - tgtEvents.count) stray target event(s) not in \(tgtName)") } var placeholderSet = Set() var occupied: [Block] = [] var placeholdersByOccurrenceID: [String: EKEvent] = [:] var placeholdersByTime: [String: EKEvent] = [:] for tv in tgtEvents { // Defensive: should already be filtered, but double-check target identity guard tv.calendar.calendarIdentifier == tgt.calendarIdentifier else { continue } if let ts = tv.startDate, let te = tv.endDate { let timeKey = "\(ts.timeIntervalSince1970)|\(te.timeIntervalSince1970)" if isMirrorEvent(tv, prefix: titlePrefix, placeholder: placeholderTitle) { placeholderSet.insert(timeKey) placeholdersByTime[timeKey] = tv let parsed = parseMirrorURL(tv.url) if let sid = parsed.srcEventID, let occ = parsed.occ { let key = "\(sid)|\(occ.timeIntervalSince1970)" placeholdersByOccurrenceID[key] = tv } } occupied.append(Block(start: ts, end: te, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) } } occupied = coalesce(occupied) var created = 0 var skipped = 0 var updated = 0 // Cross-route loop guard: unique key generator for (source, occurrence/time, target) func guardKey(for blk: Block, targetID: String) -> String { if trackByID, let sid = blk.srcEventID { let occ = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 return "\(srcCal.calendarIdentifier)|\(sid)|\(occ)|\(targetID)" } else { return "\(srcCal.calendarIdentifier)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)|\(targetID)" } } func createOrUpdateIfNeeded(_ blk: Block) { // Cross-route loop guard: skip if this (source occurrence -> target) was handled earlier this click let gKey = guardKey(for: blk, targetID: tgt.calendarIdentifier) if sessionGuard.contains(gKey) { skipped += 1 log("- SKIP loop-guard [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") return } // Privacy-aware title/notes (strip our prefix so it never doubles up) let baseSourceTitle = stripPrefix(blk.label, prefix: titlePrefix) let effectiveTitle = hideDetails ? placeholderTitle : (baseSourceTitle.isEmpty ? placeholderTitle : baseSourceTitle) let titleSuffix = hideDetails ? "" : (baseSourceTitle.isEmpty ? "" : " — \(baseSourceTitle)") let displayTitle = (titlePrefix.isEmpty ? "" : titlePrefix) + effectiveTitle // Fallback 0: if an existing mirrored event has the exact same time, update it let exactTimeKey = "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)" if let existingByTime = placeholdersByTime[exactTimeKey] { if !writeEnabled { sessionGuard.insert(gKey) log("~ WOULD UPDATE [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end) [title: \(displayTitle)]") updated += 1 return } existingByTime.title = displayTitle existingByTime.startDate = blk.start existingByTime.endDate = blk.end existingByTime.isAllDay = false if !hideDetails && copyDescription { existingByTime.notes = blk.notes } else { existingByTime.notes = nil } let sid0 = blk.srcEventID ?? "" let occ0 = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 existingByTime.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid0)|\(occ0)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") do { try store.save(existingByTime, span: .thisEvent, commit: true) log("✓ UPDATED [\(srcName) -> \(tgtName)] (by time) \(blk.start) -> \(blk.end)") placeholderSet.insert(exactTimeKey) placeholdersByTime[exactTimeKey] = existingByTime occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)]) sessionGuard.insert(gKey) updated += 1 } catch { log("Update failed: \(error.localizedDescription)") } return } // If we can track by source event ID and we have one, prefer updating the existing placeholder if trackByID, let sid = blk.srcEventID { let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 let lookupKey = "\(sid)|\(occTS)" if let existing = placeholdersByOccurrenceID[lookupKey] { let curS = existing.startDate ?? blk.start let curE = existing.endDate ?? blk.end let needsUpdate = abs(curS.timeIntervalSince(blk.start)) > SAME_TIME_TOL_MIN*60 || abs(curE.timeIntervalSince(blk.end)) > SAME_TIME_TOL_MIN*60 if !needsUpdate { skipped += 1 return } if !writeEnabled { sessionGuard.insert(gKey) log("~ WOULD UPDATE [\(srcName) -> \(tgtName)] \(curS) -> \(curE) TO \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]") updated += 1 return } existing.startDate = blk.start existing.endDate = blk.end existing.title = displayTitle existing.isAllDay = false if !hideDetails && copyDescription { existing.notes = blk.notes } else { existing.notes = nil } existing.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") do { try store.save(existing, span: .thisEvent, commit: true) log("✓ UPDATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") placeholderSet.insert("\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") let timeKey = "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)" placeholdersByTime[timeKey] = existing occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)]) placeholdersByOccurrenceID[lookupKey] = existing sessionGuard.insert(gKey) updated += 1 } catch { log("Update failed: \(error.localizedDescription)") } return } } // No existing placeholder for this block; dedupe by exact times let k = "\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)" if placeholderSet.contains(k) { skipped += 1; return } if !writeEnabled { sessionGuard.insert(gKey) log("+ WOULD CREATE [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)\(titleSuffix) [title: \(displayTitle)]") return } // Invariant: never write to the source calendar by mistake guard tgt.calendarIdentifier != srcCal.calendarIdentifier else { skipped += 1; log("- SKIP invariant: target is source [\(srcName)]"); return } let newEv = EKEvent(eventStore: store) newEv.calendar = tgt newEv.title = displayTitle newEv.startDate = blk.start newEv.endDate = blk.end newEv.isAllDay = false if !hideDetails && copyDescription { newEv.notes = blk.notes } let sid = blk.srcEventID ?? "" let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 newEv.url = URL(string: "mirror://\(tgt.calendarIdentifier)|\(srcCal.calendarIdentifier)|\(sid)|\(occTS)|\(blk.start.timeIntervalSince1970)|\(blk.end.timeIntervalSince1970)") newEv.availability = .busy do { try store.save(newEv, span: .thisEvent, commit: true) created += 1 log("✓ CREATED [\(srcName) -> \(tgtName)] \(blk.start) -> \(blk.end)") placeholderSet.insert(k) occupied = coalesce(occupied + [Block(start: blk.start, end: blk.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)]) if !sid.isEmpty { let key = "\(sid)|\(occTS)" placeholdersByOccurrenceID[key] = newEv } let timeKey = k placeholdersByTime[timeKey] = newEv sessionGuard.insert(gKey) } catch { log("Save failed: \(error.localizedDescription)") } } for b in baseBlocks { switch overlapMode { case .allow: createOrUpdateIfNeeded(b) case .skipCovered: if fullyCovered(occupied, block: b, tolMin: SAME_TIME_TOL_MIN) { log("- SKIP covered [\(srcName) -> \(tgtName)] \(b.start) -> \(b.end)") skipped += 1 } else { createOrUpdateIfNeeded(b) } case .fillGaps: let gaps = gapsWithin(occupied, in: b) if gaps.isEmpty { log("- SKIP no gaps [\(srcName) -> \(tgtName)] \(b.start) -> \(b.end)") skipped += 1 } else { for g in gaps { createOrUpdateIfNeeded(g) } } } } log("[Summary → \(tgtName)] created=\(created), updated=\(updated), skipped=\(skipped)") // Auto-delete placeholders whose source instance no longer exists if autoDeleteMissing { let validKeys: Set = Set(baseBlocks.compactMap { blk in if (mergeGapMin == 0), let sid = blk.srcEventID { let occTS = blk.occurrence?.timeIntervalSince1970 ?? blk.start.timeIntervalSince1970 return "\(sid)|\(occTS)" } return nil }) if !validKeys.isEmpty { var removed = 0 for (_, ev) in placeholdersByOccurrenceID { if ev.calendar.calendarIdentifier != tgt.calendarIdentifier { continue } let parsed = parseMirrorURL(ev.url) if let sid = parsed.srcEventID, let occ = parsed.occ { let k = "\(sid)|\(occ.timeIntervalSince1970)" if !validKeys.contains(k) { if !writeEnabled { log("~ WOULD DELETE (missing source) [\(srcName) -> \(tgtName)] \(ev.startDate ?? windowStart) -> \(ev.endDate ?? windowEnd)") } else { do { try store.remove(ev, span: .thisEvent, commit: true); removed += 1 } catch { log("Delete failed: \(error.localizedDescription)") } } } } } if removed > 0 { log("[Cleanup missing for \(tgtName)] deleted=\(removed)") } } } } } // MARK: - Export / Import Settings private struct SettingsPayload: Codable { var daysBack: Int var daysForward: Int var mergeGapHours: Int var hideDetails: Bool var copyDescription: Bool var mirrorAllDay: Bool var filterByWorkHours: Bool = false var workHoursStart: Int = 9 var workHoursEnd: Int = 17 var excludedTitleFilters: [String] = [] var mirrorAcceptedOnly: Bool = false var overlapMode: String var titlePrefix: String var placeholderTitle: String var autoDeleteMissing: Bool var routes: [Route] // optional metadata var appVersion: String? var exportedAt: Date = Date() } private func makeSnapshot() -> SettingsPayload { SettingsPayload( daysBack: daysBack, daysForward: daysForward, mergeGapHours: mergeGapHours, hideDetails: hideDetails, copyDescription: copyDescription, mirrorAllDay: mirrorAllDay, filterByWorkHours: filterByWorkHours, workHoursStart: workHoursStart, workHoursEnd: workHoursEnd, excludedTitleFilters: excludedTitleFilterList, mirrorAcceptedOnly: mirrorAcceptedOnly, overlapMode: overlapMode.rawValue, titlePrefix: titlePrefix, placeholderTitle: placeholderTitle, autoDeleteMissing: autoDeleteMissing, routes: routes, appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, exportedAt: Date() ) } private func applySnapshot(_ s: SettingsPayload) { daysBack = s.daysBack daysForward = s.daysForward mergeGapHours = s.mergeGapHours mergeGapMin = max(0, s.mergeGapHours * 60) hideDetails = s.hideDetails copyDescription = s.copyDescription mirrorAllDay = s.mirrorAllDay filterByWorkHours = s.filterByWorkHours workHoursStart = s.workHoursStart workHoursEnd = s.workHoursEnd excludedTitleFiltersRaw = s.excludedTitleFilters.joined(separator: "\n") mirrorAcceptedOnly = s.mirrorAcceptedOnly overlapMode = OverlapMode(rawValue: s.overlapMode) ?? .allow titlePrefix = s.titlePrefix placeholderTitle = s.placeholderTitle autoDeleteMissing = s.autoDeleteMissing routes = s.routes clampWorkHours() } private func exportSettings() { let panel = NSSavePanel() panel.allowedFileTypes = ["json"] panel.nameFieldStringValue = "BusyMirror-Settings.json" panel.canCreateDirectories = true panel.isExtensionHidden = false if panel.runModal() == .OK, let url = panel.url { do { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] let data = try encoder.encode(makeSnapshot()) try data.write(to: url, options: Data.WritingOptions.atomic) log("✓ Exported settings to \(url.path)") } catch { log("✗ Export failed: \(error.localizedDescription)") } } } private func importSettings() { let panel = NSOpenPanel() panel.allowedFileTypes = ["json"] panel.allowsMultipleSelection = false panel.canChooseDirectories = false if panel.runModal() == .OK, let url = panel.url { do { let data = try Data(contentsOf: url) let snap = try JSONDecoder().decode(SettingsPayload.self, from: data) applySnapshot(snap) saveSettingsToDefaults() log("✓ Imported settings from \(url.lastPathComponent)") } catch { log("✗ Import failed: \(error.localizedDescription)") } } } // MARK: - Settings persistence (UserDefaults) private let settingsDefaultsKey = "settings.v2" private let legacyRoutesDefaultsKey = "routes.v1" private func saveSettingsToDefaults() { do { let data = try JSONEncoder().encode(makeSnapshot()) UserDefaults.standard.set(data, forKey: settingsDefaultsKey) } catch { log("✗ Failed to save settings: \(error.localizedDescription)") } } private func loadSettingsFromDefaults() { let defaults = UserDefaults.standard if let data = defaults.data(forKey: settingsDefaultsKey) { do { let snap = try JSONDecoder().decode(SettingsPayload.self, from: data) applySnapshot(snap) } catch { log("✗ Failed to load settings: \(error.localizedDescription)") } return } // Legacy fallback: routes-only payload guard let legacyData = defaults.data(forKey: legacyRoutesDefaultsKey) else { return } do { let decodedRoutes = try JSONDecoder().decode([Route].self, from: legacyData) routes = decodedRoutes clampWorkHours() saveSettingsToDefaults() // upgrade stored format } catch { log("✗ Failed to load routes: \(error.localizedDescription)") } } // MARK: - Filters private func isOutsideWorkHours(_ startDate: Date, calendar: Calendar, startMinutes: Int, endMinutes: Int) -> Bool { guard endMinutes > startMinutes else { return false } let comps = calendar.dateComponents([.hour, .minute], from: startDate) guard let hour = comps.hour else { return false } let minute = comps.minute ?? 0 let start = hour * 60 + minute return start < startMinutes || start >= endMinutes } private func shouldSkip(event: EKEvent, filters: [String]) -> Bool { guard !filters.isEmpty else { return false } let rawTitle = (event.title ?? "").lowercased() let strippedTitle = stripPrefix(event.title, prefix: titlePrefix).lowercased() return filters.contains { token in rawTitle.contains(token) || strippedTitle.contains(token) } } private func clampWorkHours() { let clampedStart = min(max(workHoursStart, 0), 23) if clampedStart != workHoursStart { workHoursStart = clampedStart } let clampedEnd = min(max(workHoursEnd, 1), 24) if clampedEnd != workHoursEnd { workHoursEnd = clampedEnd } if workHoursEnd <= workHoursStart { let adjustedEnd = min(workHoursStart + 1, 24) if workHoursEnd != adjustedEnd { workHoursEnd = adjustedEnd } } } // MARK: - Logging func log(_ s: String) { logText.append("\n" + s) } // MARK: - Block helpers func key(_ b: Block) -> String { "\(b.start.timeIntervalSince1970)|\(b.end.timeIntervalSince1970)" } func mergeBlocks(_ blocks: [Block], gapMinutes: Int) -> [Block] { guard !blocks.isEmpty else { return [] } let sorted = blocks.sorted { $0.start < $1.start } var out: [Block] = [] var cur = Block(start: sorted[0].start, end: sorted[0].end, srcEventID: nil, label: nil, notes: nil, occurrence: nil) for b in sorted.dropFirst() { let gap = b.start.timeIntervalSince(cur.end) / 60.0 if gap <= Double(gapMinutes) { if b.end > cur.end { cur = Block(start: cur.start, end: b.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil) } } else { out.append(cur) cur = Block(start: b.start, end: b.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil) } } out.append(cur) return out } func coalesce(_ segs: [Block]) -> [Block] { mergeBlocks(segs, gapMinutes: 0) } func fullyCovered(_ mergedSegs: [Block], block: Block, tolMin: Double) -> Bool { for s in mergedSegs { if s.start <= block.start.addingTimeInterval(tolMin*60), s.end >= block.end.addingTimeInterval(-tolMin*60) { return true } } return false } func gapsWithin(_ mergedSegs: [Block], in block: Block) -> [Block] { if mergedSegs.isEmpty { return [Block(start: block.start, end: block.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)] } var segs: [Block] = [] for s in mergedSegs where s.end > block.start && s.start < block.end { let ss = max(s.start, block.start) let ee = min(s.end, block.end) if ee > ss { segs.append(Block(start: ss, end: ee, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) } } if segs.isEmpty { return [Block(start: block.start, end: block.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)] } let merged = coalesce(segs) var gaps: [Block] = [] var prevEnd = block.start for s in merged { if s.start > prevEnd { gaps.append(Block(start: prevEnd, end: s.start, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) } if s.end > prevEnd { prevEnd = s.end } } if prevEnd < block.end { gaps.append(Block(start: prevEnd, end: block.end, srcEventID: nil, label: nil, notes: nil, occurrence: nil)) } return gaps } // MARK: - Cleanup: delete Busy placeholders in the active window on selected targets func runCleanup() async { guard hasAccess, !calendars.isEmpty else { return } isRunning = true defer { isRunning = false } let cal = Calendar.current let todayStart = cal.startOfDay(for: Date()) let windowStart = cal.date(byAdding: .day, value: -daysBack, to: todayStart)! let windowEnd = cal.date(byAdding: .day, value: daysForward, to: todayStart)! let targetSet = Set(targetIDs) let targets = calendars.filter { targetSet.contains($0.calendarIdentifier) && $0.calendarIdentifier != calendars[sourceIndex].calendarIdentifier } log("=== Cleanup Busy placeholders in window ===") log("(Cleanup is SAFE: mirrored events detected by url prefix or title prefix ‘\(titlePrefix)’)") log("Window: \(windowStart) -> \(windowEnd)") for tgt in targets { let tgtPred = store.predicateForEvents(withStart: windowStart, end: windowEnd, calendars: [tgt]) let tgtEvents = store.events(matching: tgtPred) var delCount = 0 for ev in tgtEvents { guard isMirrorEvent(ev, prefix: titlePrefix, placeholder: placeholderTitle) else { continue } if !writeEnabled { log("~ WOULD DELETE [\(tgt.title)] \(ev.startDate ?? todayStart) -> \(ev.endDate ?? todayStart)") } else { do { try store.remove(ev, span: .thisEvent, commit: true); delCount += 1 } catch { log("Delete failed: \(error.localizedDescription)") } } } log("[Cleanup \(tgt.title)] deleted=\(delCount)") } } }