Files
busymirror/BusyMirror/ContentView.swift

1060 lines
51 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
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<String>()
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://<tgtID>|<srcCalID>|<srcEventID>|<occTS>|<startTS>|<endTS>
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(parts[3]) { occDate = Date(timeIntervalSince1970: ts) }
if parts.count >= 6, let sTS = TimeInterval(parts[4]), let eTS = TimeInterval(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 {
let id = UUID()
var sourceID: String
var targetIDs: Set<String>
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<Int>() // indices in calendars
// Stable selection storage by persistent identifiers (survives reordering)
@State private var sourceID: String? = nil
@State private var targetIDs = Set<String>()
@State private var routes: [Route] = []
@State private var daysForward: Int = 7
@State private var daysBack: Int = 1
@State private var mergeGapMin: Int = 0
@State private var mergeGapHours: Int = 0
@State private var hideDetails = true // Privacy ON by default -> use "Busy"
@State private var copyDescription = false // Only applies when hideDetails == false
@State private var overlapMode: OverlapMode = .allow
@State private var mirrorAllDay = false
@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<String>()
@State private var titlePrefix: String = "🪞 " // global title prefix for mirrored placeholders
@State private var placeholderTitle: String = "Busy" // global customizable placeholder title
@State 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
}()
// 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 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<Bool>(
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(Array(routes.enumerated()), id: \.element.id) { idx, route in
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
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()
}
Text("→ Targets:")
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)
Spacer()
Toggle("Private", isOn: Binding(get: { routes[idx].privacy }, set: { routes[idx].privacy = $0 }))
.help("If ON, mirror as \(titlePrefix)\(placeholderTitle) with no notes. If OFF, mirror source title (and optionally notes).")
Text("·").foregroundStyle(.secondary)
HStack(spacing: 6) {
Text("Merge gap:")
TextField("0", value: Binding(
get: { routes[idx].mergeGapHours },
set: { routes[idx].mergeGapHours = max(0, $0) }
), 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)
}
Toggle("Copy desc", isOn: Binding(
get: { routes[idx].copyNotes },
set: { routes[idx].copyNotes = $0 }
))
.disabled(isRunning || routes[idx].privacy)
.help("If ON and Private is OFF, copy the source events notes/description into the placeholder.")
HStack(spacing: 6) {
Text("Overlap:")
Picker("Overlap", selection: Binding(
get: { routes[idx].overlap },
set: { routes[idx].overlap = $0 }
)) {
ForEach(OverlapMode.allCases) { m in Text(m.rawValue).tag(m) }
}
.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.")
}
.disabled(isRunning)
Toggle("Allday", isOn: Binding(
get: { routes[idx].allDay },
set: { routes[idx].allDay = $0 }
))
.disabled(isRunning)
.help("Mirror allday events for this source.")
Text("·").foregroundStyle(.secondary)
Button(role: .destructive) { routes.remove(at: idx) } label: { Text("Remove") }
}
}
.padding(6)
.background(.quaternary.opacity(0.1))
.cornerRadius(6)
}
}
}
}
@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)
Picker("Overlap mode", selection: $overlapMode) {
ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.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("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 {
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) {
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)
// Save globals
let prevPrivacy = hideDetails
let prevCopy = copyDescription
let prevGapH = mergeGapHours
let prevGapM = mergeGapMin
let prevOverlap = overlapMode
let prevAllDay = mirrorAllDay
// Apply per-route
hideDetails = r.privacy
copyDescription = r.copyNotes
mergeGapHours = max(0, r.mergeGapHours)
mergeGapMin = mergeGapHours * 60
overlapMode = r.overlap
mirrorAllDay = r.allDay
await runMirror()
// Restore globals
hideDetails = prevPrivacy
copyDescription = prevCopy
mergeGapHours = prevGapH
mergeGapMin = prevGapM
overlapMode = prevOverlap
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) {
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) {
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()
mergeGapHours = mergeGapMin / 60
tryRunCLIIfPresent()
enforceNoSourceInTargets()
}
.onChange(of: sourceIndex) { oldValue, 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 sources ID, drop it
enforceNoSourceInTargets()
}
}
// 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": overlapMode = .allow
case "skipcovered", "skip": overlapMode = .skipCovered
case "fillgaps", "gaps": overlapMode = .fillGaps
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 }
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
func requestAccess() {
log("Requesting calendar access…")
if #available(macOS 14.0, *) {
store.requestFullAccessToEvents { granted, _ in
DispatchQueue.main.async {
hasAccess = granted
if granted { reloadCalendars() }
log(granted ? "Access granted." : "Access denied.")
}
}
} else {
store.requestAccess(to: .event) { granted, _ in
DispatchQueue.main.async {
hasAccess = granted
if granted { reloadCalendars() }
log(granted ? "Access granted." : "Access denied.")
}
}
}
}
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
for ev in srcEvents {
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)")
}
// 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<String>()
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<String> = 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: - 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)")
}
}
}